From b42b5640c22e3edd69792a83aad08379529fdb39 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:44:48 +0530 Subject: [PATCH 1/7] fix(unify): disable active write controls for read-only members on Feedback Data (#8570) --- .../components/feedback-records-table.tsx | 45 +++++++++++-------- .../components/edit-feedback-source-modal.tsx | 1 + .../sources/components/mapping-field.tsx | 4 ++ .../sources/components/mapping-ui.tsx | 7 +++ .../ui/components/input-combo-box/index.tsx | 12 +++-- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/apps/web/modules/ee/unify-feedback/components/feedback-records-table.tsx b/apps/web/modules/ee/unify-feedback/components/feedback-records-table.tsx index 0351df65024a..430075656fdf 100644 --- a/apps/web/modules/ee/unify-feedback/components/feedback-records-table.tsx +++ b/apps/web/modules/ee/unify-feedback/components/feedback-records-table.tsx @@ -85,6 +85,7 @@ interface FeedbackRecordRowProps { contactId?: string; locale: string; t: TFunction; + canWrite: boolean; isSelected: boolean; onSelectChange: (checked: boolean) => void; onClick: () => void; @@ -422,7 +423,7 @@ export const FeedbackRecordsTable = ({
- + {canWrite && } @@ -433,13 +434,15 @@ export const FeedbackRecordsTable = ({ - + {canWrite && ( + + )} @@ -452,7 +455,7 @@ export const FeedbackRecordsTable = ({ {isEmpty ? ( - + {canWrite && ( + + )} diff --git a/apps/web/modules/ee/unify-feedback/sources/components/edit-feedback-source-modal.tsx b/apps/web/modules/ee/unify-feedback/sources/components/edit-feedback-source-modal.tsx index d471c9bc2073..db2fc7d02018 100644 --- a/apps/web/modules/ee/unify-feedback/sources/components/edit-feedback-source-modal.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/components/edit-feedback-source-modal.tsx @@ -377,6 +377,7 @@ export const EditFeedbackSourceModal = ({ mappings={mappings} onMappingsChange={setMappings} feedbackSourceType={feedbackSource.type} + disabled={isReadOnly} /> diff --git a/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx b/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx index bec8a37d8bdb..ab27e7ce19aa 100644 --- a/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/components/mapping-field.tsx @@ -65,6 +65,7 @@ interface FormTargetFieldProps { autoMapState?: TAutoMapState; autoMapSourceColumn?: string; preview?: string; + disabled?: boolean; } export const FormTargetField = ({ @@ -77,6 +78,7 @@ export const FormTargetField = ({ autoMapState, autoMapSourceColumn, preview, + disabled = false, }: FormTargetFieldProps) => { const { t } = useTranslation(); const [isEditingFixed, setIsEditingFixed] = useState(false); @@ -311,6 +313,7 @@ export const FormTargetField = ({ searchPlaceholder={t("common.search")} emptyDropdownText={t("workspace.surveys.edit.no_option_found")} showSearch + disabled={disabled} comboboxClasses="h-9 w-full max-w-none [&_[role=combobox]]:h-9" /> @@ -319,6 +322,7 @@ export const FormTargetField = ({ size="sm" variant="secondary" onClick={openFixedValueEditor} + disabled={disabled} aria-label={t("workspace.unify.csv_fixed_value_action")} className="shrink-0"> diff --git a/apps/web/modules/ee/unify-feedback/sources/components/mapping-ui.tsx b/apps/web/modules/ee/unify-feedback/sources/components/mapping-ui.tsx index 25ff49502298..f3df173f79ff 100644 --- a/apps/web/modules/ee/unify-feedback/sources/components/mapping-ui.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/components/mapping-ui.tsx @@ -17,6 +17,8 @@ interface MappingUIProps { feedbackSourceType: TFeedbackSourceType; confidenceByTargetId?: Record; sampleRow?: Record; + /** When true, all mapping controls render read-only (e.g. for viewers). */ + disabled?: boolean; } const toAutoMapState = (confidence?: TMappingConfidence): TAutoMapState | undefined => { @@ -31,6 +33,7 @@ export function MappingUI({ feedbackSourceType, confidenceByTargetId, sampleRow, + disabled = false, }: MappingUIProps) { switch (feedbackSourceType) { case "csv": @@ -41,6 +44,7 @@ export function MappingUI({ onMappingsChange={onMappingsChange} confidenceByTargetId={confidenceByTargetId} sampleRow={sampleRow} + disabled={disabled} /> ); default: @@ -54,6 +58,7 @@ interface CsvMappingFormProps { onMappingsChange: (mappings: TFieldMapping[]) => void; confidenceByTargetId?: Record; sampleRow?: Record; + disabled?: boolean; } const CsvMappingForm = ({ @@ -62,6 +67,7 @@ const CsvMappingForm = ({ onMappingsChange, confidenceByTargetId, sampleRow, + disabled = false, }: CsvMappingFormProps) => { const { t } = useTranslation(); const [advancedOpen, setAdvancedOpen] = useState(false); @@ -113,6 +119,7 @@ const CsvMappingForm = ({ autoMapState={autoMapState} autoMapSourceColumn={sourceColumnName} preview={isResponseValue ? responseValuePreview : undefined} + disabled={disabled} /> ); }; diff --git a/apps/web/modules/ui/components/input-combo-box/index.tsx b/apps/web/modules/ui/components/input-combo-box/index.tsx index f00161bbcbcc..5bc3671577a2 100644 --- a/apps/web/modules/ui/components/input-combo-box/index.tsx +++ b/apps/web/modules/ui/components/input-combo-box/index.tsx @@ -66,6 +66,7 @@ export interface InputComboboxProps { comboboxClasses?: string; emptyDropdownText?: string; iconClassName?: string; + disabled?: boolean; } // Helper to flatten all options and their children @@ -146,6 +147,7 @@ export const InputCombobox: React.FC = ({ comboboxClasses, emptyDropdownText, iconClassName = "h-5 w-5 text-slate-400", + disabled = false, }) => { const { t } = useTranslation(); const resolvedSearchPlaceholder = searchPlaceholder ?? t("common.search"); @@ -274,12 +276,14 @@ export const InputCombobox: React.FC = ({
{withInput && inputType !== "dropdown" && ( = ({ /> )} - +
{inputType === "dropdown" ? ( From 4171b58c941d35d211d08386e9661b735446f861 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:28:29 +0530 Subject: [PATCH 2/7] feat(charts): language-stable option grouping via value_id + canonical value_text [ENG-1673] (#8464) Co-authored-by: Johannes Co-authored-by: Claude Opus 4.8 --- apps/web/i18n.lock | 18 +- .../web/lib/feedback-source/transform.test.ts | 168 ++++-- apps/web/lib/feedback-source/transform.ts | 132 ++++- apps/web/locales/de-DE.json | 18 +- apps/web/locales/en-US.json | 18 +- apps/web/locales/es-ES.json | 18 +- apps/web/locales/fr-FR.json | 18 +- apps/web/locales/hu-HU.json | 18 +- apps/web/locales/ja-JP.json | 18 +- apps/web/locales/nl-NL.json | 18 +- apps/web/locales/pt-BR.json | 18 +- apps/web/locales/pt-PT.json | 18 +- apps/web/locales/ro-RO.json | 18 +- apps/web/locales/ru-RU.json | 18 +- apps/web/locales/sv-SE.json | 18 +- apps/web/locales/tr-TR.json | 18 +- apps/web/locales/zh-Hans-CN.json | 18 +- apps/web/locales/zh-Hant-TW.json | 18 +- .../ee/analysis/charts/actions.test.ts | 431 +++++++++++++- .../web/modules/ee/analysis/charts/actions.ts | 16 +- .../components/advanced-chart-builder.tsx | 74 +-- .../charts/components/chart-preview.tsx | 9 +- .../charts/components/chart-renderer.tsx | 354 +++++++----- .../charts/components/data-viewer.tsx | 13 +- .../components/filter-field-combobox.tsx | 143 +++++ .../charts/components/filters-panel.tsx | 77 ++- .../analysis/charts/hooks/use-chart-dialog.ts | 11 +- .../charts/hooks/use-chart-query.test.ts | 82 ++- .../analysis/charts/hooks/use-chart-query.ts | 15 +- .../analysis/charts/lib/chart-utils.test.ts | 29 + .../ee/analysis/charts/lib/chart-utils.ts | 21 + .../ee/analysis/charts/lib/option-grouping.ts | 199 +++++++ .../components/dashboard-detail-client.tsx | 21 +- .../components/dashboard-widget-data.tsx | 19 +- .../pages/dashboard-detail-page.tsx | 16 +- .../ee/analysis/lib/query-builder.test.ts | 39 ++ .../modules/ee/analysis/lib/query-builder.ts | 21 +- .../ee/analysis/lib/schema-definition.ts | 116 ++-- .../web/modules/ee/analysis/types/analysis.ts | 6 + apps/web/modules/ee/unify-feedback/actions.ts | 110 +--- .../feedback-record-form-drawer.tsx | 538 +++--------------- .../components/feedback-records-table.tsx | 68 +-- .../ee/unify-feedback/lib/utils.test.ts | 21 +- .../modules/ee/unify-feedback/lib/utils.ts | 30 - apps/web/modules/ee/unify-feedback/types.ts | 69 --- .../ui/components/multi-select/index.tsx | 2 +- .../formbricks/cube/schema/FeedbackRecords.js | 12 + docker/cube/schema/FeedbackRecords.js | 12 + 48 files changed, 1870 insertions(+), 1292 deletions(-) create mode 100644 apps/web/modules/ee/analysis/charts/components/filter-field-combobox.tsx create mode 100644 apps/web/modules/ee/analysis/charts/lib/option-grouping.ts diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 2835bee39fd9..b4e30ba42ace 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -1650,6 +1650,7 @@ checksums: workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6 workspace/analysis/charts/field_label_sentiment_average: 6351961495851db39f8968f2995d0514 workspace/analysis/charts/field_label_sentiment_score: 3bec3c4ec9c18ca77a9fb07963ec79da + workspace/analysis/charts/field_label_source_id: 134a9a7d473508c5623ac724a5ba4be9 workspace/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61 workspace/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570 workspace/analysis/charts/field_label_surprise_count: dc72d86e49a621972c24fc445554a38d @@ -1696,6 +1697,7 @@ checksums: workspace/analysis/charts/no_data_returned: 73949b8732604b9e2d22d046cc4a6ef9 workspace/analysis/charts/no_data_returned_for_chart: b9ff6c85697c683f40b3d0c05eeb2046 workspace/analysis/charts/no_data_source_available: 0b247d065eaf2d0fc1c8ec558eebfba6 + workspace/analysis/charts/no_fields_found: 084d71950d12a793edad8dbede5328ea workspace/analysis/charts/no_grouping: e3a6943e61407600cae057e0833a482d workspace/analysis/charts/no_valid_data_to_display: d1ba2b0686520c0a2c62ee73daa1c9c9 workspace/analysis/charts/no_values_found: c86fa6b3d7f7e255525cd2f9330a9295 @@ -1713,6 +1715,7 @@ checksums: workspace/analysis/charts/save_and_add_to_dashboard: a76ed91c62dae10c5f8a9d48cbacd566 workspace/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566 workspace/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566 + workspace/analysis/charts/search_field: 8585baa2984e1d037e2d83815f0df1b6 workspace/analysis/charts/search_value: c92a85848cc34ea1717daee8d9a34c86 workspace/analysis/charts/select_data_source: 983394bc0182b65ec68f713a46b97302 workspace/analysis/charts/select_data_source_first: 82a02846de9d6351595c97a0929f3b9a @@ -3675,8 +3678,6 @@ checksums: workspace/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b workspace/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02 workspace/teams/team_settings_description: 52f91883b9ceb6de83efbf8efd4f11c0 - workspace/unify/add_feedback_record: 19cf2b1fef0ca1400f2400e7ee681ea0 - workspace/unify/add_feedback_record_description: 94bca46246ba7353049b33742554b4c0 workspace/unify/add_feedback_source: 69a643c09bd3a3053797ddcf68c2219a workspace/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e workspace/unify/ai_enrichment: ab208e42a764628a034c350bc5f3a2be @@ -3729,8 +3730,6 @@ checksums: workspace/unify/csv_source_context_hint: eaf09edede7b8105712903992f8964ee workspace/unify/csv_unmapped_columns: b94a8b5c3cf9df8859e5ac484a14969c workspace/unify/csv_unmapped_columns_explainer: 58cdfc3c06c141f156288dc434f8e0ca - workspace/unify/custom_source_type: d931a8a74d3a5becd568e398107979da - workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc workspace/unify/data_origin: 9a62ac5378411b0b7735c01dbeeb454f workspace/unify/default_source_name_csv: ef4060fef24c4fec064987b9d2a9fa4b workspace/unify/default_source_name_formbricks: 9088b01e1085b1cf42412597ebeea051 @@ -3738,8 +3737,6 @@ checksums: workspace/unify/delete_feedback_record_confirmation: 389f5a27a76d825d8751cc8fa20589ea workspace/unify/delete_feedback_records_confirmation: 9867caf985bfd77e4df9be2164298dd5 workspace/unify/delete_source_confirmation: 43b048de6338be1757ade6bf5029b010 - workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941 - workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e workspace/unify/dismiss: c2f298165cbfc17041b795065bfeb57c workspace/unify/download_sample_csv: 76975598e61d4eaeb04fc14da8b0a837 workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9 @@ -3758,13 +3755,10 @@ checksums: workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8 workspace/unify/feedback_data: eb1cf6e9ed7e9e1fdf8d4c950653700a workspace/unify/feedback_directory: b964b69f8f1bec9e5cd8b8e7307568b9 - workspace/unify/feedback_record_created_successfully: 0ff30472085f1313a5ad53837c83e7c1 workspace/unify/feedback_record_deleted_successfully: ea58f53841cc48dc974f1589f4718da6 workspace/unify/feedback_record_details: 823f3353db049a9d263ef31405054cda workspace/unify/feedback_record_details_description: 0b6f908154161241ce6bdeb4a2acaecd workspace/unify/feedback_record_mcp: 8d9aeea97bbb0ad4c4adbf508057f284 - workspace/unify/feedback_record_updated_successfully: cb40ef4b924e21fa627ebe6809d1d826 - workspace/unify/feedback_record_value_required: b54d4d86f82071a93dc979e8eb359cf0 workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388 workspace/unify/feedback_records_deleted_successfully: 176c27a362d593a62355904c50bb0e9e workspace/unify/feedback_records_partially_deleted: dff8cd8482e8053ce4186e6b42d0aee8 @@ -3794,9 +3788,7 @@ checksums: workspace/unify/manage_directories: 0ad23f38f8185bb987a2caf5f09334dc workspace/unify/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8 workspace/unify/metadata: 695d4f7da261ba76e3be4de495491028 - workspace/unify/metadata_key: c478d228673f59fa556208ece60452f6 workspace/unify/metadata_read_only_entries: 1934fee46c0a117f4926b61cc3d2d602 - workspace/unify/metadata_value: 8d69be1f5a20d9473a33c35670dff216 workspace/unify/missing_feedback_source_title: 9ab1b8d54b4da72dd00ce03fe3b698b5 workspace/unify/no_feedback_directory_available: 3b8def4bb369948f17da401bd64e942b workspace/unify/no_feedback_directory_linked_admin_description: 0a7db006ea1a8e3e667da0e9c4483bb2 @@ -3816,7 +3808,6 @@ checksums: workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20 workspace/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862 workspace/unify/select_feedback_directory: 36ea24993aacd0749be6124cbf6861aa - workspace/unify/select_feedback_record_source_type: 10997fcbea2f93e756888cf7a7476fdf workspace/unify/select_questions: 13c79b8c284423eb6140534bf2137e56 workspace/unify/select_questions_for_import: 5f360f6c821ae548120ab1c8f4f4a9ac workspace/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc @@ -3935,8 +3926,7 @@ checksums: workspace/unify/value: 34b0eaa85808b15cbc4be94c64d0146b workspace/unify/value_boolean: bbdcd3f46954b6304b9069e94e1371ab workspace/unify/value_date: c8d705d1975affc01c002324725fec3f - workspace/unify/value_id: ed21d97b8ab035ba89fb3f5f073229bd - workspace/unify/value_id_hint: 9691fae95d3fdd6d9ffa3d2ab5a4bd3c + workspace/unify/value_id: 5e0222661639b5ef6aab7ccff11363a1 workspace/unify/value_number: 1f14da79d14bd7b1c2324141f4470675 workspace/unify/value_text: e097a597cc507c716401ad18255de578 workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9 diff --git a/apps/web/lib/feedback-source/transform.test.ts b/apps/web/lib/feedback-source/transform.test.ts index 59a37a114eb8..884566dc0c45 100644 --- a/apps/web/lib/feedback-source/transform.test.ts +++ b/apps/web/lib/feedback-source/transform.test.ts @@ -177,11 +177,17 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0].value_boolean).toBe(true); }); - test("transforms categorical (multi-select) field to comma-separated text", () => { + test("splits a multi-select answer into one record per selected option", () => { const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId); - expect(result).toHaveLength(1); - expect(result[0].value_text).toBe("feat-a, feat-b"); + expect(result).toHaveLength(2); + // el-multi has no choices in this fixture, so entries stay unmatched (no value_id) but still + // split, each tied back to the question via field_group_id and to the respondent via submission_id. + expect(result.map((r) => r.field_id)).toEqual(["el-multi__feat-a", "el-multi__feat-b"]); + expect(result.map((r) => r.value_text)).toEqual(["feat-a", "feat-b"]); + expect(result.every((r) => r.field_group_id === "el-multi")).toBe(true); + expect(result.every((r) => r.submission_id === "resp-1")).toBe(true); + expect(result.every((r) => r.value_id === undefined)).toBe(true); }); test("uses customFieldLabel when provided", () => { @@ -238,9 +244,18 @@ describe("transformResponseToFeedbackRecords", () => { test("transforms all mappings in a single call", () => { const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings, mockTenantId); - expect(result).toHaveLength(6); + // el-multi splits into one record per selected option, so it contributes two rows. + expect(result).toHaveLength(7); const fieldIds = result.map((r) => r.field_id); - expect(fieldIds).toEqual(["el-text", "el-nps", "el-rating", "el-date", "el-bool", "el-multi"]); + expect(fieldIds).toEqual([ + "el-text", + "el-nps", + "el-rating", + "el-date", + "el-bool", + "el-multi__feat-a", + "el-multi__feat-b", + ]); }); test("falls back to 'Untitled' for element with no headline", () => { @@ -329,12 +344,29 @@ describe("transformResponseToFeedbackRecords", () => { }); test("handles single string value for categorical field", () => { + // Provide an element with a choices array so the choice-lookup path does not crash. + // The submitted value does not match any choice, so it passes through unchanged. + const surveyWithChoices = { + ...mockSurvey, + blocks: [ + { + elements: [ + { + id: "el-multi", + type: "multipleChoiceMulti", + headline: { default: "Select features" }, + choices: [{ id: "ch-a", label: { default: "Option A" } }], + }, + ], + }, + ], + } as unknown as TSurvey; const response = { ...mockResponse, data: { "el-multi": "single-choice" }, } as unknown as TResponse; const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })]; - const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId); + const result = transformResponseToFeedbackRecords(response, surveyWithChoices, mappings, mockTenantId); expect(result[0].value_text).toBe("single-choice"); }); @@ -349,24 +381,26 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0].value_text).not.toBe("[object Object]"); }); - test("creates a record for a ranking response (string array)", () => { + test("joins array values to comma-separated text for non-choice elements", () => { + // An element absent from the survey definition has no type, so the generic path stores the + // raw array joined. The per-option split only applies to multipleChoiceMulti elements. const response = { ...mockResponse, - data: { "el-multi": ["LabelA", "LabelB", "LabelC"] }, + data: { "el-array": ["LabelA", "LabelB", "LabelC"] }, } as unknown as TResponse; - const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })]; + const mappings = [createMapping({ elementId: "el-array", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId); expect(result).toHaveLength(1); - expect(result[0].field_id).toBe("el-multi"); + expect(result[0].field_id).toBe("el-array"); expect(result[0].value_text).toBe("LabelA, LabelB, LabelC"); }); - test("creates a record for an empty ranking response (empty array)", () => { + test("joins an empty array to an empty string for non-choice elements", () => { const response = { ...mockResponse, - data: { "el-multi": [] }, + data: { "el-array": [] }, } as unknown as TResponse; - const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })]; + const mappings = [createMapping({ elementId: "el-array", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId); expect(result).toHaveLength(1); expect(result[0].value_text).toBe(""); @@ -436,6 +470,7 @@ describe("transformResponseToFeedbackRecords", () => { field_id: "el-matrix__row-1", field_label: "Speed", field_type: "categorical", + // value_text = default-language label (canonical) — for default-language responses this equals the submitted label. value_text: "Good", }); expect(result[1]).toMatchObject({ @@ -597,7 +632,7 @@ describe("transformResponseToFeedbackRecords", () => { }); }); - describe("choice values across languages (labels stored as submitted, identity via value_id)", () => { + describe("choice values across languages (value_text = default-language label (canonical), value_id = stable choice id)", () => { const bilingualSurvey = { id: "survey-1", name: "Bilingual Survey", @@ -636,43 +671,55 @@ describe("transformResponseToFeedbackRecords", () => { language, }) as unknown as TResponse; - test("stores the submitted-language label for a single select, with the choice id as identity", () => { + test("stores the default-language label for a single select answered in another language, with the choice id as identity", () => { const response = buildResponse({ "el-gender": "ذكر" }, "ar"); const mappings = [createMapping({ elementId: "el-gender", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, bilingualSurvey, mappings, mockTenantId); expect(result).toHaveLength(1); - expect(result[0].value_text).toBe("ذكر"); + // value_text = default-language label (canonical); value_id is the stable grouping key. + expect(result[0].value_text).toBe("Male"); expect(result[0].value_id).toBe("c-male"); expect(result[0].language).toBe("ar"); }); - test("stores submitted-language labels for a multi select answered in another language", () => { + test("splits a multi select answered in another language into per-option records with default-language labels", () => { const response = buildResponse({ "el-feats": ["سرعة", "جودة"] }, "ar"); const mappings = [createMapping({ elementId: "el-feats", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, bilingualSurvey, mappings, mockTenantId); - expect(result).toHaveLength(1); - expect(result[0].value_text).toBe("سرعة, جودة"); + // One record per selection; each value_text is the canonical default-language label and each + // carries its stable choice id, regardless of the response language. + expect(result).toHaveLength(2); + expect(result.map((r) => r.value_text)).toEqual(["Speed", "Quality"]); + expect(result.map((r) => r.value_id)).toEqual(["c-speed", "c-quality"]); + expect(result.map((r) => r.field_id)).toEqual(["el-feats__c-speed", "el-feats__c-quality"]); + expect(result.every((r) => r.field_group_id === "el-feats")).toBe(true); }); - test("passes through values that match no choice label (other / free text)", () => { + test("splits a multi select and passes unmatched free text through per option", () => { const response = buildResponse({ "el-feats": ["سرعة", "something else"] }, "ar"); const mappings = [createMapping({ elementId: "el-feats", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, bilingualSurvey, mappings, mockTenantId); - expect(result[0].value_text).toBe("سرعة, something else"); + // Matched entry canonicalizes to its default-language label with its choice id; the unmatched + // free text passes through and carries no value_id (el-feats offers no "other" option). + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ value_text: "Speed", value_id: "c-speed" }); + expect(result[1]).toMatchObject({ value_text: "something else" }); + expect(result[1].value_id).toBeUndefined(); }); - test("leaves default-language answers unchanged", () => { + test("stores the original label for default-language answers (unchanged since submitted=default)", () => { const response = buildResponse({ "el-gender": "Female" }, "default"); const mappings = [createMapping({ elementId: "el-gender", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, bilingualSurvey, mappings, mockTenantId); + // value_text = default-language label (canonical); for default-language answers this equals the submitted label. expect(result[0].value_text).toBe("Female"); }); @@ -684,12 +731,13 @@ describe("transformResponseToFeedbackRecords", () => { const result = transformResponseToFeedbackRecords(response, bilingualSurvey, mappings, mockTenantId); + // value_text = default-language label (canonical); value_id = stable choice id. expect(result[0].value_text).toBe("Female"); expect(result[0].value_id).toBe("c-female"); expect(result[0].language).toBe("en-US"); }); - test("stores the submitted-language column label for a matrix answered in another language", () => { + test("stores the default-language label for a matrix column answered in another language", () => { const matrixSurvey = { id: "survey-1", name: "Matrix Survey", @@ -720,7 +768,8 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0]).toMatchObject({ field_id: "el-matrix__row-1", field_label: "Speed", - value_text: "جيد", + // value_text = default-language label (canonical); value_id = stable column id. + value_text: "Good", value_id: "col-1", }); }); @@ -770,14 +819,15 @@ describe("transformResponseToFeedbackRecords", () => { language, }) as unknown as TResponse; - test("sets value_id to the matched choice id for a single select", () => { + test("sets value_id to the matched choice id for a single select; value_text stores the default-language label (canonical)", () => { const response = buildResponse({ "el-gender": "ذكر" }, "ar"); const mappings = [createMapping({ elementId: "el-gender", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId); expect(result[0].value_id).toBe("c-male"); - expect(result[0].value_text).toBe("ذكر"); + // value_text = default-language label (canonical), regardless of the response language. + expect(result[0].value_text).toBe("Male"); }); test("sets value_id for default-language single select answers too", () => { @@ -787,6 +837,8 @@ describe("transformResponseToFeedbackRecords", () => { const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId); expect(result[0].value_id).toBe("c-female"); + // value_text = default-language label (canonical); for default-language answers this equals the submitted label. + expect(result[0].value_text).toBe("Female"); }); test("sets value_id when the response carries the default language's concrete code (ENG-1673 regression)", () => { @@ -796,6 +848,7 @@ describe("transformResponseToFeedbackRecords", () => { const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId); expect(result[0].value_id).toBe("c-female"); + // value_text = default-language label (canonical); same as submitted since answered in default language. expect(result[0].value_text).toBe("Female"); }); @@ -809,16 +862,64 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0].value_text).toBe("self-described"); }); - // Multi-select answers stay unmatched and split per language (ENG-1702 follow-up): - // the joined record cannot carry one id per selected option. - test("omits value_id for multi select (joined record cannot carry multiple ids)", () => { + // Multi-select splits into one record per selected option (ENG-1702), so each option keeps + // its own stable value_id instead of collapsing into a single joined record. + test("sets a value_id per selected option for multi select", () => { const response = buildResponse({ "el-feats": ["Speed", "Quality"] }); const mappings = [createMapping({ elementId: "el-feats", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId); - expect(result[0].value_id).toBeUndefined(); - expect(result[0].value_text).toBe("Speed, Quality"); + expect(result).toHaveLength(2); + expect(result.map((r) => r.value_id)).toEqual(["c-speed", "c-quality"]); + expect(result.map((r) => r.value_text)).toEqual(["Speed", "Quality"]); + expect(result.map((r) => r.field_id)).toEqual(["el-feats__c-speed", "el-feats__c-quality"]); + expect(result.every((r) => r.field_group_id === "el-feats")).toBe(true); + }); + + test("emits no records for an empty multi select selection", () => { + const response = buildResponse({ "el-feats": [] }); + const mappings = [createMapping({ elementId: "el-feats", hubFieldType: "categorical" })]; + + const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId); + + expect(result).toEqual([]); + }); + + test("groups unmatched multi-select entries under the stable 'other' id when the element offers one", () => { + const otherSurvey = { + id: "survey-1", + name: "Other Survey", + languages: bilingualLanguages, + blocks: [ + { + elements: [ + { + id: "el-feats-other", + type: "multipleChoiceMulti", + headline: { default: "Select features" }, + choices: [ + { id: "c-speed", label: { default: "Speed" } }, + { id: "other", label: { default: "Other" } }, + ], + }, + ], + }, + ], + } as unknown as TSurvey; + const response = buildResponse({ "el-feats-other": ["Speed", "my own idea"] }); + const mappings = [createMapping({ elementId: "el-feats-other", hubFieldType: "categorical" })]; + + const result = transformResponseToFeedbackRecords(response, otherSurvey, mappings, mockTenantId); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ value_text: "Speed", value_id: "c-speed" }); + // The free-text entry groups under the stable "other" id and keeps the submitted text. + expect(result[1]).toMatchObject({ + field_id: "el-feats-other__other", + value_text: "my own idea", + value_id: "other", + }); }); test("omits value_id for non-choice elements", () => { @@ -830,7 +931,7 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0].value_id).toBeUndefined(); }); - test("sets value_id to the matched column id for matrix answers", () => { + test("sets value_id to the matched column id for matrix answers; value_text is the default-language label (canonical)", () => { const matrixSurvey = { id: "survey-1", name: "Matrix Survey", @@ -859,7 +960,8 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0]).toMatchObject({ field_id: "el-matrix__row-1", - value_text: "جيد", + // value_text = default-language label (canonical); value_id = stable column id. + value_text: "Good", value_id: "col-1", }); }); diff --git a/apps/web/lib/feedback-source/transform.ts b/apps/web/lib/feedback-source/transform.ts index 339f26011cbb..e9425740aa09 100644 --- a/apps/web/lib/feedback-source/transform.ts +++ b/apps/web/lib/feedback-source/transform.ts @@ -7,6 +7,7 @@ import type { TSurveyElementChoice, TSurveyMatrixElement, TSurveyMatrixElementChoice, + TSurveyMultipleChoiceElement, TSurveyRankingElement, } from "@formbricks/types/surveys/elements"; import type { TSurvey } from "@formbricks/types/surveys/types"; @@ -36,18 +37,24 @@ const findChoiceByLabel = { + if (!Array.isArray(value) || value.length === 0) return []; + + const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(element); + const choices = element.choices ?? []; + const hasOtherOption = + element.otherOptionPlaceholder !== undefined || choices.some((choice) => choice.id === "other"); + const records: FeedbackRecordCreateParams[] = []; + + value.forEach((entry) => { + if (typeof entry !== "string" || entry === "") return; + + const choice = findChoiceByLabel(choices, entry, lookupLanguage); + // Matched choice → canonical default-language label + stable id. Unmatched entry → the "other" + // free text; group it under the stable "other" id when the element offers that option. + const valueId = choice?.id ?? (hasOtherOption ? "other" : undefined); + const canonicalValue = choice ? getChoiceLabel(choice, "default") : entry; + const valueFields = convertValueToHubFields(canonicalValue, mapping.hubFieldType); + + records.push({ + ...baseFields, + field_id: `${element.id}__${valueId ?? entry}`, + field_type: mapping.hubFieldType, + field_label: fieldLabel, + field_group_id: element.id, + field_group_label: fieldLabel, + metadata: { question_type: "multipleChoiceMulti" }, + ...valueFields, + ...(valueId ? { value_id: valueId } : {}), + }); + }); + + return records; +}; + +/** + * Normalize an element's answer for storage. Choice elements canonicalize labels and resolve + * value_id via normalizeChoiceValue; every other element type stores the value as submitted. + * + * "Other" free-text answers never match a choice label, so they'd otherwise carry no + * value_id and each distinct free-text string would chart as its own bucket. When the + * element offers an "other" option, group them all under the stable "other" choice id + * (the survey convention: the other option's choice id is "other"). + */ +const normalizeElementValue = ( + element: TSurveyElement | undefined, + value: TResponseDataValue, + lookupLanguage: string +): NormalizedChoiceValue => { + const isChoiceElement = + element && + (element.type === TSurveyElementTypeEnum.MultipleChoiceSingle || + element.type === TSurveyElementTypeEnum.MultipleChoiceMulti); + if (!isChoiceElement) return { value }; + + const normalized = normalizeChoiceValue(element.choices, value, lookupLanguage); + + if ( + !normalized.valueId && + typeof value === "string" && + (element.otherOptionPlaceholder !== undefined || element.choices.some((c) => c.id === "other")) + ) { + normalized.valueId = "other"; + } + + return normalized; +}; + /** * Transform a Formbricks survey response into FeedbackRecord payloads. * Called from the pipeline handler when a response is created/finished. * - * Matrix and ranking questions expand into one record per row/item, sharing a - * field_group_id so Hub analytics can aggregate across them. + * Matrix, ranking, and multi-select questions expand into one record per row/item/option, + * sharing a field_group_id so Hub analytics can aggregate across them. */ export function transformResponseToFeedbackRecords( response: TResponse, @@ -285,16 +379,18 @@ export function transformResponseToFeedbackRecords( continue; } - const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(element); + // Multi-select splits into one record per selected option so each keeps its own stable + // value_id (ENG-1702). A single string answer falls through to the generic path below. + if (element?.type === TSurveyElementTypeEnum.MultipleChoiceMulti && Array.isArray(value)) { + feedbackRecords.push( + ...expandMultiChoiceToRecords(element, mapping, value, baseFields, lookupLanguage) + ); + continue; + } - const isChoiceElement = - element && - (element.type === TSurveyElementTypeEnum.MultipleChoiceSingle || - element.type === TSurveyElementTypeEnum.MultipleChoiceMulti); - const normalized = isChoiceElement - ? normalizeChoiceValue(element.choices, value, lookupLanguage) - : { value }; + const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(element); + const normalized = normalizeElementValue(element, value, lookupLanguage); const valueFields = convertValueToHubFields(normalized.value, mapping.hubFieldType); feedbackRecords.push({ diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 05d6b64ed864..65db74c3aeb3 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Stimmung", "field_label_sentiment_average": "Stimmung: Durchschnitt", "field_label_sentiment_score": "Stimmungswert", + "field_label_source_id": "Quellen-ID", "field_label_source_name": "Quellenname", "field_label_source_type": "Quellentyp", "field_label_surprise_count": "Emotion: Überraschung", @@ -1759,6 +1760,7 @@ "no_data_returned": "Keine Daten zum Anzeigen vorhanden, bitte ändere deine Einstellungen.", "no_data_returned_for_chart": "Keine Daten für Diagramm zurückgegeben", "no_data_source_available": "Diesem Workspace ist kein Feedback-Datensatz zugewiesen.", + "no_fields_found": "Keine Felder gefunden", "no_grouping": "Keine (nur Filter)", "no_valid_data_to_display": "Keine gültigen Daten zur Anzeige", "no_values_found": "Keine Werte gefunden", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Speichern und zum Dashboard hinzufügen", "save_chart": "Diagramm speichern", "save_chart_dialog_title": "Diagramm speichern", + "search_field": "Feld suchen...", "search_value": "Wert suchen...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Sieh nach, welche Teams auf diesen Workspace zugreifen können." }, "unify": { - "add_feedback_record": "Feedback-Datensatz hinzufügen", - "add_feedback_record_description": "Erstellen Sie manuell einen Feedback-Datensatz.", "add_feedback_source": "Feedback-Quelle hinzufügen", "add_source": "Quelle hinzufügen", "ai_enrichment": "KI-Anreicherung", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Zeigt an, woher dieser Feedback-Stapel stammt.", "csv_unmapped_columns": "Nicht zugeordnete Spalten ({count}): {columns}", "csv_unmapped_columns_explainer": "Diese Spalten werden von keinem Feedback-Datensatz-Feld verwendet. Sie werden beim Import ignoriert.", - "custom_source_type": "Benutzerdefinierter Quelltyp", - "custom_source_type_placeholder": "Geben Sie den benutzerdefinierten Quelltyp ein", "data_origin": "Data origin", "default_source_name_csv": "CSV-Import", "default_source_name_formbricks": "Formbricks-Umfragequelle", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Dadurch wird der Feedback-Eintrag dauerhaft gelöscht und aus dem verknüpften Datensatz entfernt.", "delete_feedback_records_confirmation": "Dadurch werden {count} Feedback-Einträge dauerhaft gelöscht und aus dem verknüpften Datensatz entfernt.", "delete_source_confirmation": "Wenn du diese Quelle löschst, werden zukünftige Importe gestoppt und das gespeicherte Mapping entfernt. Vorhandene Feedback-Datensätze bleiben weiterhin verfügbar.", - "discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.", - "discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?", "dismiss": "Dismiss", "download_sample_csv": "Beispiel-CSV herunterladen", "edit_csv_mapping": "CSV-Zuordnung bearbeiten", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden", "feedback_data": "Feedback Data", "feedback_directory": "Feedback-Datensatz", - "feedback_record_created_successfully": "Feedback-Datensatz erfolgreich erstellt", "feedback_record_deleted_successfully": "Feedback-Eintrag erfolgreich gelöscht", "feedback_record_details": "Details zum Feedback-Datensatz", "feedback_record_details_description": "Überprüfen und aktualisieren Sie die Felder des Feedback-Datensatzes.", "feedback_record_mcp": "MCP-Server verwenden", - "feedback_record_updated_successfully": "Feedback-Datensatz erfolgreich aktualisiert", - "feedback_record_value_required": "Für den ausgewählten Feldtyp ist ein Wert erforderlich", "feedback_records": "Feedback-Einträge", "feedback_records_deleted_successfully": "{count} Feedback-Einträge gelöscht", "feedback_records_partially_deleted": "{succeeded} von {total} Feedback-Einträgen gelöscht", @@ -3954,9 +3948,7 @@ "manage_directories": "Datensätze verwalten", "manage_feedback_sources": "Feedbackquellen verwalten", "metadata": "Metadaten", - "metadata_key": "Metadatenschlüssel", "metadata_read_only_entries": "Schreibgeschützte Metadatenwerte (keine Zeichenfolge)", - "metadata_value": "Metadatenwert", "missing_feedback_source_title": "Feedback-Quelle fehlt?", "no_feedback_directory_available": "Diesem Workspace ist kein Feedback-Datensatz zugeordnet. Erstelle oder weise zuerst einen zu.", "no_feedback_directory_linked_admin_description": "Feedback-Datensätze bringen Feedback-Daten aus mehreren Workspaces zusammen. Verbinde einen Datensatz mit diesem Workspace, um loszulegen.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Wähle eine Umfrage aus, um ihre Fragen zu sehen", "select_a_value": "Wähle einen Wert aus...", "select_feedback_directory": "Datensatz auswählen", - "select_feedback_record_source_type": "Wählen Sie den Quelltyp aus", "select_questions": "Fragen auswählen", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Wähle die Art der Feedback-Quelle aus, die Du verbinden möchtest.", @@ -4095,8 +4086,7 @@ "value": "Wert", "value_boolean": "Wert (Boolescher Wert)", "value_date": "Wert (Datum)", - "value_id": "Options-ID", - "value_id_hint": "Stabile ID der ausgewählten Option in der Quellumfrage. Sie hält diesen Datensatz über Labeländerungen und Sprachen hinweg mit seiner Option verknüpft und wird automatisch verwaltet.", + "value_id": "Wert-ID", "value_number": "Wert (Anzahl)", "value_text": "Wert (Text)" }, diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index a80112eb760e..031b902579bc 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentiment", "field_label_sentiment_average": "Sentiment: Average", "field_label_sentiment_score": "Sentiment Score", + "field_label_source_id": "Source ID", "field_label_source_name": "Source Name", "field_label_source_type": "Source Type", "field_label_surprise_count": "Emotion: Surprise", @@ -1759,6 +1760,7 @@ "no_data_returned": "No data to display, please change your settings.", "no_data_returned_for_chart": "No data returned for chart", "no_data_source_available": "No feedback dataset is assigned to this workspace.", + "no_fields_found": "No fields found", "no_grouping": "None (filter only)", "no_valid_data_to_display": "No valid data to display", "no_values_found": "No values found", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Save & add to dashboard", "save_chart": "Save Chart", "save_chart_dialog_title": "Save Chart", + "search_field": "Search field...", "search_value": "Search value...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "See which teams can access this workspace." }, "unify": { - "add_feedback_record": "Add feedback record", - "add_feedback_record_description": "Create a feedback record manually.", "add_feedback_source": "Add feedback source", "add_source": "Add source", "ai_enrichment": "AI enrichment", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifies where this batch of feedback came from.", "csv_unmapped_columns": "Unmapped columns ({count}): {columns}", "csv_unmapped_columns_explainer": "These columns aren't used by any Feedback Record field. They'll be ignored at import.", - "custom_source_type": "Custom source type", - "custom_source_type_placeholder": "Enter custom source type", "data_origin": "Data origin", "default_source_name_csv": "CSV Import", "default_source_name_formbricks": "Formbricks Survey Source", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "This will permanently delete the feedback record and remove it from the connected dataset.", "delete_feedback_records_confirmation": "This will permanently delete {count} feedback records and remove them from the connected dataset.", "delete_source_confirmation": "Deleting this source will stop future imports and remove its saved mapping. Existing feedback records will remain available.", - "discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.", - "discard_feedback_record_changes_title": "Discard unsaved changes?", "dismiss": "Dismiss", "download_sample_csv": "Download sample CSV", "edit_csv_mapping": "Edit CSV mapping", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Failed to load feedback records", "feedback_data": "Feedback Data", "feedback_directory": "Feedback Dataset", - "feedback_record_created_successfully": "Feedback record created successfully", "feedback_record_deleted_successfully": "Feedback record deleted successfully", "feedback_record_details": "Feedback record details", "feedback_record_details_description": "Review and update feedback record fields.", "feedback_record_mcp": "Use MCP server", - "feedback_record_updated_successfully": "Feedback record updated successfully", - "feedback_record_value_required": "A value is required for the selected field type", "feedback_records": "Feedback Records", "feedback_records_deleted_successfully": "{count} feedback records deleted", "feedback_records_partially_deleted": "{succeeded} of {total} feedback records deleted", @@ -3954,9 +3948,7 @@ "manage_directories": "Manage datasets", "manage_feedback_sources": "Manage feedback sources", "metadata": "Metadata", - "metadata_key": "Metadata key", "metadata_read_only_entries": "Read-only metadata values (non-string)", - "metadata_value": "Metadata value", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_directory_available": "No feedback dataset assigned to this workspace. Create or assign one first.", "no_feedback_directory_linked_admin_description": "Feedback datasets bring feedback data from multiple workspaces together. Connect a dataset to this workspace to get started.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Select a survey to see its questions", "select_a_value": "Select a value...", "select_feedback_directory": "Select a dataset", - "select_feedback_record_source_type": "Select source type", "select_questions": "Select questions", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Select the type of feedback source you want to connect.", @@ -4095,8 +4086,7 @@ "value": "Value", "value_boolean": "Value (Boolean)", "value_date": "Value (Date)", - "value_id": "Option ID", - "value_id_hint": "Stable ID of the selected option in the source survey. It keeps this record linked to its option across label edits and languages, and is managed automatically.", + "value_id": "Value ID", "value_number": "Value (Number)", "value_text": "Value (Text)" }, diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 79be1ce53579..540ea1948bb4 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentimiento", "field_label_sentiment_average": "Sentimiento: Promedio", "field_label_sentiment_score": "Puntuación de sentimiento", + "field_label_source_id": "ID de origen", "field_label_source_name": "Nombre de origen", "field_label_source_type": "Tipo de origen", "field_label_surprise_count": "Emoción: Sorpresa", @@ -1759,6 +1760,7 @@ "no_data_returned": "No hay datos para mostrar, por favor cambia tu configuración.", "no_data_returned_for_chart": "No se han devuelto datos para el gráfico", "no_data_source_available": "No hay ningún conjunto de datos de feedback asignado a este espacio de trabajo.", + "no_fields_found": "No se encontraron campos", "no_grouping": "Ninguno (solo filtro)", "no_valid_data_to_display": "No hay datos válidos para mostrar", "no_values_found": "No se encontraron valores", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Guardar y agregar al panel", "save_chart": "Guardar gráfico", "save_chart_dialog_title": "Guardar gráfico", + "search_field": "Buscar campo...", "search_value": "Buscar valor...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Consulta qué equipos pueden acceder a este espacio de trabajo." }, "unify": { - "add_feedback_record": "Agregar registro de comentarios", - "add_feedback_record_description": "Cree un registro de comentarios manualmente.", "add_feedback_source": "Añadir fuente de feedback", "add_source": "Añadir fuente", "ai_enrichment": "Enriquecimiento de IA", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifica de dónde proviene este lote de feedback.", "csv_unmapped_columns": "Columnas sin mapear ({count}): {columns}", "csv_unmapped_columns_explainer": "Estas columnas no están siendo usadas por ningún campo de registro de feedback. Se ignorarán durante la importación.", - "custom_source_type": "Tipo de fuente personalizado", - "custom_source_type_placeholder": "Ingrese el tipo de fuente personalizado", "data_origin": "Data origin", "default_source_name_csv": "Importación CSV", "default_source_name_formbricks": "Fuente de encuestas Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Esto eliminará permanentemente el registro de feedback y lo quitará del conjunto de datos conectado.", "delete_feedback_records_confirmation": "Esto eliminará permanentemente {count} registros de feedback y los quitará del conjunto de datos conectado.", "delete_source_confirmation": "Eliminar esta fuente detendrá futuras importaciones y eliminará su mapeo guardado. Los registros de comentarios existentes seguirán estando disponibles.", - "discard_feedback_record_changes_description": "Sus cambios se perderán si cierra este cajón.", - "discard_feedback_record_changes_title": "¿Descartar los cambios no guardados?", "dismiss": "Dismiss", "download_sample_csv": "Descargar CSV de muestra", "edit_csv_mapping": "Editar mapeo de CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Error al cargar los registros de comentarios", "feedback_data": "Feedback Data", "feedback_directory": "Conjunto de Datos de Feedback", - "feedback_record_created_successfully": "Registro de comentarios creado correctamente", "feedback_record_deleted_successfully": "Registro de comentarios eliminado correctamente", "feedback_record_details": "Detalles del registro de comentarios", "feedback_record_details_description": "Revise y actualice los campos del registro de comentarios.", "feedback_record_mcp": "Usar servidor MCP", - "feedback_record_updated_successfully": "Registro de comentarios actualizado correctamente", - "feedback_record_value_required": "Se requiere un valor para el tipo de campo seleccionado", "feedback_records": "Registros de comentarios", "feedback_records_deleted_successfully": "{count} registros de comentarios eliminados", "feedback_records_partially_deleted": "{succeeded} de {total} registros de comentarios eliminados", @@ -3954,9 +3948,7 @@ "manage_directories": "Gestionar conjuntos de datos", "manage_feedback_sources": "Administrar fuentes de comentarios", "metadata": "Metadatos", - "metadata_key": "Clave de metadatos", "metadata_read_only_entries": "Valores de metadatos de solo lectura (no cadenas)", - "metadata_value": "Valor de metadatos", "missing_feedback_source_title": "¿Falta alguna fuente de feedback?", "no_feedback_directory_available": "No hay ningún conjunto de datos de feedback asignado a este espacio de trabajo. Crea o asigna uno primero.", "no_feedback_directory_linked_admin_description": "Los conjuntos de datos de comentarios reúnen datos de comentarios de múltiples espacios de trabajo. Conecta un conjunto de datos a este espacio de trabajo para comenzar.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Selecciona una encuesta para ver sus preguntas", "select_a_value": "Selecciona un valor...", "select_feedback_directory": "Selecciona un conjunto de datos", - "select_feedback_record_source_type": "Seleccionar tipo de fuente", "select_questions": "Seleccionar preguntas", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Selecciona el tipo de fuente de feedback que quieres conectar.", @@ -4095,8 +4086,7 @@ "value": "Valor", "value_boolean": "Valor (booleano)", "value_date": "Valor (Fecha)", - "value_id": "ID de opción", - "value_id_hint": "ID estable de la opción seleccionada en la encuesta de origen. Mantiene este registro vinculado a su opción a través de ediciones de etiquetas e idiomas, y se gestiona automáticamente.", + "value_id": "ID de valor", "value_number": "Valor (Número)", "value_text": "Valor (Texto)" }, diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 05292ec2aade..58e07d7f60f7 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentiment", "field_label_sentiment_average": "Sentiment : Moyenne", "field_label_sentiment_score": "Score de sentiment", + "field_label_source_id": "ID source", "field_label_source_name": "Nom de la source", "field_label_source_type": "Type de source", "field_label_surprise_count": "Émotion : Surprise", @@ -1759,6 +1760,7 @@ "no_data_returned": "Aucune donnée à afficher, merci de modifier tes paramètres.", "no_data_returned_for_chart": "Aucune donnée retournée pour le graphique", "no_data_source_available": "Aucun ensemble de données de feedback n'est attribué à cet espace de travail.", + "no_fields_found": "Aucun champ trouvé", "no_grouping": "Aucun (filtre uniquement)", "no_valid_data_to_display": "Aucune donnée valide à afficher", "no_values_found": "Aucune valeur trouvée", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Enregistrer et ajouter au tableau de bord", "save_chart": "Enregistrer le graphique", "save_chart_dialog_title": "Enregistrer le graphique", + "search_field": "Rechercher un champ...", "search_value": "Rechercher une valeur...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Voir quelles équipes peuvent accéder à cet espace de travail." }, "unify": { - "add_feedback_record": "Ajouter un enregistrement de commentaires", - "add_feedback_record_description": "Créez manuellement un enregistrement de commentaires.", "add_feedback_source": "Ajouter une source de feedback", "add_source": "Ajouter une source", "ai_enrichment": "Enrichissement IA", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifie d'où provient ce lot de retours.", "csv_unmapped_columns": "Colonnes non mappées ({count}) : {columns}", "csv_unmapped_columns_explainer": "Ces colonnes ne sont utilisées par aucun champ d'enregistrement de retour. Elles seront ignorées lors de l'importation.", - "custom_source_type": "Type de source personnalisé", - "custom_source_type_placeholder": "Entrez le type de source personnalisé", "data_origin": "Data origin", "default_source_name_csv": "Importation CSV", "default_source_name_formbricks": "Source de sondage Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Cette action supprimera définitivement le retour et le retirera de l'ensemble de données connecté.", "delete_feedback_records_confirmation": "Cette action supprimera définitivement {count} retours et les retirera de l'ensemble de données connecté.", "delete_source_confirmation": "La suppression de cette source arrêtera les futures importations et supprimera sa configuration de mapping enregistrée. Les enregistrements de feedback existants resteront disponibles.", - "discard_feedback_record_changes_description": "Vos modifications seront perdues si vous fermez ce tiroir.", - "discard_feedback_record_changes_title": "Supprimer les modifications non enregistrées ?", "dismiss": "Dismiss", "download_sample_csv": "Télécharger un exemple de CSV", "edit_csv_mapping": "Modifier le mappage CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback", "feedback_data": "Feedback Data", "feedback_directory": "Ensemble de données de feedback", - "feedback_record_created_successfully": "Enregistrement de commentaires créé avec succès", "feedback_record_deleted_successfully": "Enregistrement de commentaire supprimé avec succès", "feedback_record_details": "Détails de l'enregistrement des commentaires", "feedback_record_details_description": "Examiner et mettre à jour les champs d’enregistrement des commentaires.", "feedback_record_mcp": "Utiliser le serveur MCP", - "feedback_record_updated_successfully": "L'enregistrement des commentaires a été mis à jour avec succès", - "feedback_record_value_required": "Une valeur est requise pour le type de champ sélectionné", "feedback_records": "Enregistrements de feedback", "feedback_records_deleted_successfully": "{count} enregistrements de commentaires supprimés", "feedback_records_partially_deleted": "{succeeded} enregistrements de commentaires supprimés sur {total}", @@ -3954,9 +3948,7 @@ "manage_directories": "Gérer les ensembles de données", "manage_feedback_sources": "Gérer les sources de commentaires", "metadata": "Métadonnées", - "metadata_key": "Clé de métadonnées", "metadata_read_only_entries": "Valeurs de métadonnées en lecture seule (non-chaîne)", - "metadata_value": "Valeur des métadonnées", "missing_feedback_source_title": "Il manque une source de feedback ?", "no_feedback_directory_available": "Aucun ensemble de données de feedback n'est assigné à cet espace de travail. Crée ou assigne-en un d'abord.", "no_feedback_directory_linked_admin_description": "Les jeux de données de retours rassemblent les données de retours de plusieurs espaces de travail. Connecte un jeu de données à cet espace de travail pour commencer.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Sélectionnez une enquête pour voir ses questions", "select_a_value": "Sélectionnez une valeur...", "select_feedback_directory": "Sélectionner un ensemble de données", - "select_feedback_record_source_type": "Sélectionnez le type de source", "select_questions": "Sélectionner les questions", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Sélectionnez le type de source de feedback que vous souhaitez connecter.", @@ -4095,8 +4086,7 @@ "value": "Valeur", "value_boolean": "Valeur (booléenne)", "value_date": "Valeur (Date)", - "value_id": "ID de l'option", - "value_id_hint": "ID stable de l'option sélectionnée dans le questionnaire source. Il maintient ce dossier lié à son option lors de modifications de libellé et de changements de langue, et est géré automatiquement.", + "value_id": "ID de valeur", "value_number": "Valeur (Nombre)", "value_text": "Valeur (texte)" }, diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 78b0bb3262e1..bb850fba9d82 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Hangulat", "field_label_sentiment_average": "Vélemény: Átlag", "field_label_sentiment_score": "Hangulatpontszám", + "field_label_source_id": "Forrásazonosító", "field_label_source_name": "Forrás neve", "field_label_source_type": "Forrás típusa", "field_label_surprise_count": "Érzelem: Meglepetés", @@ -1759,6 +1760,7 @@ "no_data_returned": "Nincs megjeleníthető adat, kérem, módosítsa a beállításait.", "no_data_returned_for_chart": "Nem lett adat visszaadva a diagramhoz", "no_data_source_available": "Ehhez a munkaterülethez nincs hozzárendelve visszajelzési adatkészlet.", + "no_fields_found": "Nem található mező", "no_grouping": "Nincs (csak szűrő)", "no_valid_data_to_display": "Nincsenek érvényes adatok a megjelenítéshez", "no_values_found": "Nem található érték", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Mentés és hozzáadás a vezérlőpulthoz", "save_chart": "Diagram mentése", "save_chart_dialog_title": "Diagram mentése", + "search_field": "Mező keresése...", "search_value": "Érték keresése...", "select_data_source": "Adatforrás kiválasztása", "select_data_source_first": "Először válasszon egy adatforrást", @@ -3835,8 +3838,6 @@ "team_settings_description": "Annak megtekintése, hogy mely csapatok férhetnek hozzá ehhez a munkaterülethez." }, "unify": { - "add_feedback_record": "Visszajelzési rekord hozzáadása", - "add_feedback_record_description": "Visszajelzési rekord létrehozása kézzel.", "add_feedback_source": "Visszajelzési forrás hozzáadása", "add_source": "Forrás hozzáadása", "ai_enrichment": "Mesterséges intelligencia alapú dúsítás", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Azonosítja, hogy honnan származik ez a visszajelzésköteg.", "csv_unmapped_columns": "Leképezetlen oszlopok ({count}): {columns}", "csv_unmapped_columns_explainer": "Ezeket az oszlopokat nem használja egyetlen visszajelzési rekord sem. Mellőzve lesznek az importáláskor.", - "custom_source_type": "Egyéni forrástípus", - "custom_source_type_placeholder": "Egyéni forrástípus megadása", "data_origin": "Data origin", "default_source_name_csv": "CSV-importálás", "default_source_name_formbricks": "Formbricks-kérdőív forrása", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Ez véglegesen törli a visszajelzési rekordot, és eltávolítja a csatlakoztatott adatkészletből.", "delete_feedback_records_confirmation": "Ez véglegesen töröl {count} visszajelzési rekordot, és eltávolítja azokat a csatlakoztatott adatkészletből.", "delete_source_confirmation": "A forrás törlése le fogja állítani a jövőbeli importálásokat, és eltávolítja a mentett leképezését. A meglévő visszajelzési rekordok elérhetők maradnak.", - "discard_feedback_record_changes_description": "A változtatásai el fognak veszni, ha bezárja ezt a fiókot.", - "discard_feedback_record_changes_title": "Elveti a mentetlen változtatásokat?", "dismiss": "Dismiss", "download_sample_csv": "Minta CSV letöltése", "edit_csv_mapping": "CSV-leképezés szerkesztése", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat", "feedback_data": "Feedback Data", "feedback_directory": "Visszajelzési Adatkészlet", - "feedback_record_created_successfully": "A visszajelzési rekord sikeresen létrehozva", "feedback_record_deleted_successfully": "A visszajelzési rekord sikeresen törölve", "feedback_record_details": "Visszajelzési rekord részletei", "feedback_record_details_description": "Visszajelzési rekord mezőinek értékelése és frissítése.", "feedback_record_mcp": "MCP szerver használata", - "feedback_record_updated_successfully": "A visszajelzési rekord sikeresen frissítve", - "feedback_record_value_required": "Érték szükséges a kiválasztott mezőtípushoz", "feedback_records": "Visszajelzési rekordok", "feedback_records_deleted_successfully": "{count} visszajelzési rekord törölve", "feedback_records_partially_deleted": "{succeeded} / {total} visszajelzési rekord törölve", @@ -3954,9 +3948,7 @@ "manage_directories": "Adatkészletek kezelése", "manage_feedback_sources": "Visszajelzési források kezelése", "metadata": "Metaadatok", - "metadata_key": "Metaadatkulcs", "metadata_read_only_entries": "Csak olvasható metaadatértékek (nem karakterlánc)", - "metadata_value": "Metaadatérték", "missing_feedback_source_title": "Hiányzik a visszajelzési forrás?", "no_feedback_directory_available": "Nincs visszajelzési adatkészlet hozzárendelve ehhez a munkaterülethez. Először hozzon létre vagy rendeljen hozzá egyet.", "no_feedback_directory_linked_admin_description": "A visszajelzési adatkészletek több munkaterületről származó visszajelzési adatokat egyesítenek. Csatlakoztasson egy adatkészletet ehhez a munkaterülethez a kezdéshez.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Kérdőív kiválasztása a kérdései megtekintéséhez", "select_a_value": "Érték kiválasztása…", "select_feedback_directory": "Válasszon egy adatkészletet", - "select_feedback_record_source_type": "Forrástípus kiválasztása", "select_questions": "Kérdések kiválasztása", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát.", @@ -4095,8 +4086,7 @@ "value": "Érték", "value_boolean": "Érték (logikai)", "value_date": "Érték (dátum)", - "value_id": "Opció-azonosító", - "value_id_hint": "A forrásfelmérésben kiválasztott opció stabil azonosítója. Ez tartja a rekordot kapcsolatban az opciójával a címkeszerkesztések és nyelvek között, és automatikusan kerül kezelésre.", + "value_id": "Érték azonosító", "value_number": "Érték (szám)", "value_text": "Érték (szöveg)" }, diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 3e46b6d85f86..365ec5b1d642 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "センチメント", "field_label_sentiment_average": "センチメント:平均", "field_label_sentiment_score": "センチメントスコア", + "field_label_source_id": "ソースID", "field_label_source_name": "ソース名", "field_label_source_type": "ソースタイプ", "field_label_surprise_count": "感情:驚き", @@ -1759,6 +1760,7 @@ "no_data_returned": "表示するデータがありません。設定を変更してください。", "no_data_returned_for_chart": "チャートのデータが返されませんでした", "no_data_source_available": "このワークスペースにはフィードバックデータセットが割り当てられていません。", + "no_fields_found": "フィールドが見つかりません", "no_grouping": "なし(フィルターのみ)", "no_valid_data_to_display": "表示する有効なデータがありません", "no_values_found": "値が見つかりません", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "保存してダッシュボードに追加", "save_chart": "チャートを保存", "save_chart_dialog_title": "チャートを保存", + "search_field": "フィールドを検索...", "search_value": "値を検索...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "このワークスペースにアクセスできるチームを確認します。" }, "unify": { - "add_feedback_record": "フィードバックレコードを追加する", - "add_feedback_record_description": "フィードバック記録を手動で作成します。", "add_feedback_source": "フィードバックソースを追加", "add_source": "ソースを追加", "ai_enrichment": "AIエンリッチメント", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "このフィードバックのバッチがどこから来たかを識別します。", "csv_unmapped_columns": "未マッピングの列({count}件): {columns}", "csv_unmapped_columns_explainer": "これらの列はフィードバックレコードのどのフィールドにも使用されていません。インポート時に無視されます。", - "custom_source_type": "カスタムソースタイプ", - "custom_source_type_placeholder": "カスタムソースタイプを入力してください", "data_origin": "Data origin", "default_source_name_csv": "CSVインポート", "default_source_name_formbricks": "Formbricksアンケートソース", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "フィードバック記録が完全に削除され、接続されたデータセットから削除されます。", "delete_feedback_records_confirmation": "{count}件のフィードバック記録が完全に削除され、接続されたデータセットから削除されます。", "delete_source_confirmation": "このソースを削除すると、今後のインポートが停止され、保存されたマッピングが削除されます。既存のフィードバック記録は引き続き利用可能です。", - "discard_feedback_record_changes_description": "このドロワーを閉じると、変更内容は失われます。", - "discard_feedback_record_changes_title": "保存されていない変更を破棄しますか?", "dismiss": "Dismiss", "download_sample_csv": "サンプルCSVをダウンロード", "edit_csv_mapping": "CSVマッピングを編集", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました", "feedback_data": "Feedback Data", "feedback_directory": "フィードバックデータセット", - "feedback_record_created_successfully": "フィードバックレコードが正常に作成されました", "feedback_record_deleted_successfully": "フィードバック記録を削除しました", "feedback_record_details": "フィードバック記録の詳細", "feedback_record_details_description": "フィードバック レコード フィールドを確認して更新します。", "feedback_record_mcp": "MCPサーバーを使用", - "feedback_record_updated_successfully": "フィードバックレコードが正常に更新されました", - "feedback_record_value_required": "選択したフィールド タイプには値が必要です", "feedback_records": "フィードバックレコード", "feedback_records_deleted_successfully": "{count}件のフィードバック記録を削除しました", "feedback_records_partially_deleted": "{total}件中{succeeded}件のフィードバックレコードを削除しました", @@ -3954,9 +3948,7 @@ "manage_directories": "データセットを管理", "manage_feedback_sources": "フィードバックソースを管理する", "metadata": "メタデータ", - "metadata_key": "メタデータキー", "metadata_read_only_entries": "読み取り専用メタデータ値 (非文字列)", - "metadata_value": "メタデータ値", "missing_feedback_source_title": "フィードバックソースが見つかりませんか?", "no_feedback_directory_available": "このワークスペースにフィードバックデータセットが割り当てられていません。まず作成または割り当ててください。", "no_feedback_directory_linked_admin_description": "フィードバックデータセットは、複数のワークスペースからのフィードバックデータをまとめます。開始するには、このワークスペースにデータセットを接続してください。", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "フォームを選択して質問を表示", "select_a_value": "値を選択...", "select_feedback_directory": "データセットを選択", - "select_feedback_record_source_type": "ソースタイプを選択してください", "select_questions": "質問を選択", "select_questions_for_import": "Select questions for import", "select_source_type_description": "接続するフィードバックソースの種類を選択してください。", @@ -4095,8 +4086,7 @@ "value": "値", "value_boolean": "値 (ブール値)", "value_date": "値 (日付)", - "value_id": "オプションID", - "value_id_hint": "ソース調査で選択されたオプションの固定ID。ラベルの編集や言語の変更に関わらず、このレコードをオプションにリンクし続けます。自動的に管理されます。", + "value_id": "値ID", "value_number": "値(数値)", "value_text": "値 (テキスト)" }, diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 712f23b7d486..ee3ab0d32ea3 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentiment", "field_label_sentiment_average": "Sentiment: Gemiddelde", "field_label_sentiment_score": "Sentimentscore", + "field_label_source_id": "Bron-ID", "field_label_source_name": "Bronnaam", "field_label_source_type": "Brontype", "field_label_surprise_count": "Emotie: Verrassing", @@ -1759,6 +1760,7 @@ "no_data_returned": "Geen gegevens om weer te geven, pas je instellingen aan.", "no_data_returned_for_chart": "Geen gegevens geretourneerd voor diagram", "no_data_source_available": "Er is geen feedbackdataset toegewezen aan deze werkruimte.", + "no_fields_found": "Geen velden gevonden", "no_grouping": "Geen (alleen filteren)", "no_valid_data_to_display": "Geen geldige gegevens om weer te geven", "no_values_found": "Geen waarden gevonden", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Opslaan en toevoegen aan dashboard", "save_chart": "Diagram opslaan", "save_chart_dialog_title": "Diagram opslaan", + "search_field": "Zoek veld...", "search_value": "Waarde zoeken...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Bekijk welke teams toegang hebben tot deze workspace." }, "unify": { - "add_feedback_record": "Feedbackrecord toevoegen", - "add_feedback_record_description": "Maak handmatig een feedbackrecord.", "add_feedback_source": "Feedbackbron toevoegen", "add_source": "Bron toevoegen", "ai_enrichment": "AI-verrijking", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Geeft aan waar deze batch feedback vandaan komt.", "csv_unmapped_columns": "Niet-gekoppelde kolommen ({count}): {columns}", "csv_unmapped_columns_explainer": "Deze kolommen worden niet gebruikt door een Feedback Record-veld. Ze worden genegeerd bij het importeren.", - "custom_source_type": "Aangepast brontype", - "custom_source_type_placeholder": "Voer een aangepast brontype in", "data_origin": "Data origin", "default_source_name_csv": "CSV import", "default_source_name_formbricks": "Formbricks Enquêtebron", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Dit verwijdert het feedbackgegeven permanent en haalt het uit de gekoppelde dataset.", "delete_feedback_records_confirmation": "Dit verwijdert {count} feedbackgegevens permanent en haalt ze uit de gekoppelde dataset.", "delete_source_confirmation": "Het verwijderen van deze bron stopt toekomstige imports en verwijdert de opgeslagen mapping. Bestaande feedbackgegevens blijven beschikbaar.", - "discard_feedback_record_changes_description": "Als u deze lade sluit, gaan uw wijzigingen verloren.", - "discard_feedback_record_changes_title": "Niet-opgeslagen wijzigingen verwijderen?", "dismiss": "Dismiss", "download_sample_csv": "Voorbeeld-CSV downloaden", "edit_csv_mapping": "CSV-mapping bewerken", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Kan feedbackrecords niet laden", "feedback_data": "Feedback Data", "feedback_directory": "Feedback-dataset", - "feedback_record_created_successfully": "Feedbackrecord is succesvol aangemaakt", "feedback_record_deleted_successfully": "Feedbackrecord succesvol verwijderd", "feedback_record_details": "Details van feedbackrecord", "feedback_record_details_description": "Controleer en update de feedbackrecordvelden.", "feedback_record_mcp": "Gebruik MCP-server", - "feedback_record_updated_successfully": "Feedbackrecord is succesvol bijgewerkt", - "feedback_record_value_required": "Er is een waarde vereist voor het geselecteerde veldtype", "feedback_records": "Feedbackrecords", "feedback_records_deleted_successfully": "{count} feedbackrecords verwijderd", "feedback_records_partially_deleted": "{succeeded} van {total} feedbackgegevens verwijderd", @@ -3954,9 +3948,7 @@ "manage_directories": "Datasets beheren", "manage_feedback_sources": "Beheer feedbackbronnen", "metadata": "Metagegevens", - "metadata_key": "Metagegevenssleutel", "metadata_read_only_entries": "Alleen-lezen metadatawaarden (niet-tekenreeks)", - "metadata_value": "Metagegevenswaarde", "missing_feedback_source_title": "Mis je een feedbackbron?", "no_feedback_directory_available": "Geen feedback-dataset toegewezen aan deze workspace. Maak of wijs er eerst een toe.", "no_feedback_directory_linked_admin_description": "Feedback-datasets brengen feedbackgegevens uit meerdere workspaces samen. Koppel een dataset aan deze workspace om aan de slag te gaan.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Selecteer een enquête om de vragen te zien", "select_a_value": "Selecteer een waarde...", "select_feedback_directory": "Selecteer een dataset", - "select_feedback_record_source_type": "Selecteer brontype", "select_questions": "Selecteer vragen", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Selecteer het type feedbackbron dat je wilt verbinden.", @@ -4095,8 +4086,7 @@ "value": "Waarde", "value_boolean": "Waarde (Booleaans)", "value_date": "Waarde (datum)", - "value_id": "Optie-ID", - "value_id_hint": "Stabiele ID van de geselecteerde optie in de bronnenquête. Het houdt dit record gekoppeld aan zijn optie over labelbewerkingen en talen heen, en wordt automatisch beheerd.", + "value_id": "Waarde-ID", "value_number": "Waarde (getal)", "value_text": "Waarde (tekst)" }, diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index c5a76530b7aa..3b27844ac4f6 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentimento", "field_label_sentiment_average": "Sentimento: Média", "field_label_sentiment_score": "Pontuação de sentimento", + "field_label_source_id": "ID de Origem", "field_label_source_name": "Nome da fonte", "field_label_source_type": "Tipo de fonte", "field_label_surprise_count": "Emoção: Surpresa", @@ -1759,6 +1760,7 @@ "no_data_returned": "Nenhum dado para exibir, por favor altere suas configurações.", "no_data_returned_for_chart": "Nenhum dado retornado para o gráfico", "no_data_source_available": "Nenhum conjunto de dados de feedback está atribuído a este workspace.", + "no_fields_found": "Nenhum campo encontrado", "no_grouping": "Nenhum (apenas filtro)", "no_valid_data_to_display": "Nenhum dado válido para exibir", "no_values_found": "Nenhum valor encontrado", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Salvar e adicionar ao painel", "save_chart": "Salvar gráfico", "save_chart_dialog_title": "Salvar gráfico", + "search_field": "Pesquisar campo...", "search_value": "Buscar valor...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Veja quais equipes podem acessar este workspace." }, "unify": { - "add_feedback_record": "Adicionar registro de feedback", - "add_feedback_record_description": "Crie um registro de feedback manualmente.", "add_feedback_source": "Adicionar fonte de feedback", "add_source": "Adicionar fonte", "ai_enrichment": "Enriquecimento de IA", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifica de onde esse lote de feedback veio.", "csv_unmapped_columns": "Colunas não mapeadas ({count}): {columns}", "csv_unmapped_columns_explainer": "Essas colunas não são usadas por nenhum campo de Registro de Feedback. Elas serão ignoradas na importação.", - "custom_source_type": "Tipo de origem personalizado", - "custom_source_type_placeholder": "Insira o tipo de fonte personalizado", "data_origin": "Data origin", "default_source_name_csv": "Importação CSV", "default_source_name_formbricks": "Fonte de Pesquisa Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Isso excluirá permanentemente o registro de feedback e o removerá do conjunto de dados conectado.", "delete_feedback_records_confirmation": "Isso excluirá permanentemente {count} registros de feedback e os removerá do conjunto de dados conectado.", "delete_source_confirmation": "Excluir esta fonte interromperá futuras importações e removerá seu mapeamento salvo. Os registros de feedback existentes permanecerão disponíveis.", - "discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.", - "discard_feedback_record_changes_title": "Descartar alterações não salvas?", "dismiss": "Dismiss", "download_sample_csv": "Baixar CSV de exemplo", "edit_csv_mapping": "Editar mapeamento CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Falha ao carregar registros de feedback", "feedback_data": "Feedback Data", "feedback_directory": "Conjunto de Dados de Feedback", - "feedback_record_created_successfully": "Registro de feedback criado com sucesso", "feedback_record_deleted_successfully": "Registro de feedback excluído com sucesso", "feedback_record_details": "Detalhes do registro de feedback", "feedback_record_details_description": "Revise e atualize os campos de registro de feedback.", "feedback_record_mcp": "Usar servidor MCP", - "feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso", - "feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado", "feedback_records": "Registros de feedback", "feedback_records_deleted_successfully": "{count} registros de feedback excluídos", "feedback_records_partially_deleted": "{succeeded} de {total} registros de feedback excluídos", @@ -3954,9 +3948,7 @@ "manage_directories": "Gerenciar conjuntos de dados", "manage_feedback_sources": "Gerenciar fontes de feedback", "metadata": "Metadados", - "metadata_key": "Chave de metadados", "metadata_read_only_entries": "Valores de metadados somente leitura (sem string)", - "metadata_value": "Valor dos metadados", "missing_feedback_source_title": "Faltando alguma fonte de feedback?", "no_feedback_directory_available": "Nenhum conjunto de dados de feedback atribuído a este workspace. Crie ou atribua um primeiro.", "no_feedback_directory_linked_admin_description": "Conjuntos de dados de feedback reúnem dados de feedback de vários workspaces. Conecte um conjunto de dados a este workspace para começar.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Selecione uma pesquisa para ver suas perguntas", "select_a_value": "Selecione um valor...", "select_feedback_directory": "Selecionar um conjunto de dados", - "select_feedback_record_source_type": "Selecione o tipo de fonte", "select_questions": "Selecionar perguntas", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Selecione o tipo de fonte de feedback que você deseja conectar.", @@ -4095,8 +4086,7 @@ "value": "Valor", "value_boolean": "Valor (Booleano)", "value_date": "Valor (Data)", - "value_id": "ID da Opção", - "value_id_hint": "ID estável da opção selecionada na pesquisa de origem. Mantém este registro vinculado à sua opção mesmo com edições de rótulo e mudanças de idioma, sendo gerenciado automaticamente.", + "value_id": "ID do Valor", "value_number": "Valor (Número)", "value_text": "Valor (Texto)" }, diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 57300283838d..8c0a77e34f8c 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentimento", "field_label_sentiment_average": "Sentimento: Média", "field_label_sentiment_score": "Pontuação de sentimento", + "field_label_source_id": "ID de Origem", "field_label_source_name": "Nome da origem", "field_label_source_type": "Tipo de origem", "field_label_surprise_count": "Emoção: Surpresa", @@ -1759,6 +1760,7 @@ "no_data_returned": "Não há dados para apresentar, por favor altera as tuas definições.", "no_data_returned_for_chart": "Nenhum dado devolvido para o gráfico", "no_data_source_available": "Nenhum conjunto de dados de feedback está atribuído a este espaço de trabalho.", + "no_fields_found": "Nenhum campo encontrado", "no_grouping": "Nenhum (apenas filtro)", "no_valid_data_to_display": "Nenhum dado válido para exibir", "no_values_found": "Nenhum valor encontrado", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Salvar e adicionar ao painel", "save_chart": "Guardar gráfico", "save_chart_dialog_title": "Guardar gráfico", + "search_field": "Pesquisar campo...", "search_value": "Pesquisar valor...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Veja quais as equipas que podem aceder a este espaço de trabalho." }, "unify": { - "add_feedback_record": "Adicionar registro de feedback", - "add_feedback_record_description": "Crie um registro de feedback manualmente.", "add_feedback_source": "Adicionar fonte de feedback", "add_source": "Adicionar fonte", "ai_enrichment": "Enriquecimento de IA", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifica de onde veio este lote de feedback.", "csv_unmapped_columns": "Colunas não mapeadas ({count}): {columns}", "csv_unmapped_columns_explainer": "Estas colunas não são usadas por nenhum campo de Registo de Feedback. Serão ignoradas na importação.", - "custom_source_type": "Tipo de origem personalizado", - "custom_source_type_placeholder": "Insira o tipo de fonte personalizado", "data_origin": "Data origin", "default_source_name_csv": "Importação CSV", "default_source_name_formbricks": "Fonte de Inquéritos Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Isto irá eliminar permanentemente o registo de feedback e removê-lo do conjunto de dados associado.", "delete_feedback_records_confirmation": "Isto irá eliminar permanentemente {count} registos de feedback e removê-los do conjunto de dados associado.", "delete_source_confirmation": "Eliminar esta origem irá parar importações futuras e remover o seu mapeamento guardado. Os registos de feedback existentes permanecerão disponíveis.", - "discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.", - "discard_feedback_record_changes_title": "Descartar alterações não salvas?", "dismiss": "Dismiss", "download_sample_csv": "Transferir CSV de exemplo", "edit_csv_mapping": "Editar mapeamento CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Falha ao carregar registos de feedback", "feedback_data": "Feedback Data", "feedback_directory": "Conjunto de Dados de Feedback", - "feedback_record_created_successfully": "Registro de feedback criado com sucesso", "feedback_record_deleted_successfully": "Registo de feedback eliminado com sucesso", "feedback_record_details": "Detalhes do registro de feedback", "feedback_record_details_description": "Revise e atualize os campos de registro de feedback.", "feedback_record_mcp": "Usar servidor MCP", - "feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso", - "feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado", "feedback_records": "Registos de feedback", "feedback_records_deleted_successfully": "{count} registos de feedback eliminados", "feedback_records_partially_deleted": "{succeeded} de {total} registos de feedback eliminados", @@ -3954,9 +3948,7 @@ "manage_directories": "Gerir conjuntos de dados", "manage_feedback_sources": "Gerenciar fontes de feedback", "metadata": "Metadados", - "metadata_key": "Chave de metadados", "metadata_read_only_entries": "Valores de metadados somente leitura (sem string)", - "metadata_value": "Valor dos metadados", "missing_feedback_source_title": "Falta alguma fonte de feedback?", "no_feedback_directory_available": "Nenhum conjunto de dados de feedback atribuído a este espaço de trabalho. Cria ou atribui um primeiro.", "no_feedback_directory_linked_admin_description": "Os conjuntos de dados de feedback reúnem dados de feedback de vários espaços de trabalho. Liga um conjunto de dados a este espaço de trabalho para começares.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Selecione um inquérito para ver as suas perguntas", "select_a_value": "Selecione um valor...", "select_feedback_directory": "Selecionar um conjunto de dados", - "select_feedback_record_source_type": "Selecione o tipo de fonte", "select_questions": "Selecionar perguntas", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Selecione o tipo de fonte de feedback que pretende conectar.", @@ -4095,8 +4086,7 @@ "value": "Valor", "value_boolean": "Valor (Booleano)", "value_date": "Valor (Data)", - "value_id": "ID da opção", - "value_id_hint": "ID estável da opção selecionada no inquérito de origem. Mantém este registo ligado à sua opção através de edições de etiquetas e idiomas, sendo gerido automaticamente.", + "value_id": "ID de Valor", "value_number": "Valor (Número)", "value_text": "Valor (Texto)" }, diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 9befb981a090..40ab48aee398 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentiment", "field_label_sentiment_average": "Sentiment: Medie", "field_label_sentiment_score": "Scor de sentiment", + "field_label_source_id": "ID sursă", "field_label_source_name": "Nume sursă", "field_label_source_type": "Tip sursă", "field_label_surprise_count": "Emoție: Surpriză", @@ -1759,6 +1760,7 @@ "no_data_returned": "Nu există date de afișat, te rugăm să modifici setările.", "no_data_returned_for_chart": "Nu au fost returnate date pentru grafic", "no_data_source_available": "Niciun set de date cu feedback nu este atribuit acestui spațiu de lucru.", + "no_fields_found": "Nu s-au găsit câmpuri", "no_grouping": "Niciuna (doar filtru)", "no_valid_data_to_display": "Nu există date valide de afișat", "no_values_found": "Nu s-au găsit valori", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Salvați și adăugați în tabloul de bord", "save_chart": "Salvează graficul", "save_chart_dialog_title": "Salvează graficul", + "search_field": "Caută câmp...", "search_value": "Caută valoare...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Vedeți ce echipe pot accesa acest spațiu de lucru." }, "unify": { - "add_feedback_record": "Adăugați înregistrarea de feedback", - "add_feedback_record_description": "Creați manual o înregistrare de feedback.", "add_feedback_source": "Adaugă sursă de feedback", "add_source": "Adaugă sursă", "ai_enrichment": "Îmbogățire AI", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifică de unde provine acest lot de feedback.", "csv_unmapped_columns": "Coloane nemapate ({count}): {columns}", "csv_unmapped_columns_explainer": "Aceste coloane nu sunt folosite de niciun câmp din Înregistrarea de Feedback. Vor fi ignorate la import.", - "custom_source_type": "Tip sursă personalizat", - "custom_source_type_placeholder": "Introduceți tipul de sursă personalizat", "data_origin": "Data origin", "default_source_name_csv": "Import CSV", "default_source_name_formbricks": "Sursă de sondaje Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Aceasta va șterge definitiv înregistrarea de feedback și o va elimina din setul de date conectat.", "delete_feedback_records_confirmation": "Aceasta va șterge definitiv {count} înregistrări de feedback și le va elimina din setul de date conectat.", "delete_source_confirmation": "Ștergerea acestei surse va opri importurile viitoare și va elimina maparea salvată. Înregistrările de feedback existente vor rămâne disponibile.", - "discard_feedback_record_changes_description": "Modificările dvs. se vor pierde dacă închideți acest sertar.", - "discard_feedback_record_changes_title": "Renunțați la modificările nesalvate?", "dismiss": "Dismiss", "download_sample_csv": "Descarcă un CSV de exemplu", "edit_csv_mapping": "Editează maparea CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback", "feedback_data": "Feedback Data", "feedback_directory": "Set de date de feedback", - "feedback_record_created_successfully": "Înregistrare de feedback creată cu succes", "feedback_record_deleted_successfully": "Înregistrarea de feedback a fost ștearsă cu succes", "feedback_record_details": "Detaliile înregistrării feedback-ului", "feedback_record_details_description": "Examinați și actualizați câmpurile pentru înregistrarea de feedback.", "feedback_record_mcp": "Folosește serverul MCP", - "feedback_record_updated_successfully": "Înregistrarea feedback-ului a fost actualizată cu succes", - "feedback_record_value_required": "Este necesară o valoare pentru tipul de câmp selectat", "feedback_records": "Înregistrări de feedback", "feedback_records_deleted_successfully": "{count} înregistrări de feedback șterse", "feedback_records_partially_deleted": "{succeeded} din {total} înregistrări de feedback șterse", @@ -3954,9 +3948,7 @@ "manage_directories": "Gestionează seturile de date", "manage_feedback_sources": "Gestionați sursele de feedback", "metadata": "Metadate", - "metadata_key": "Cheia de metadate", "metadata_read_only_entries": "Valori de metadate numai pentru citire (fără șir)", - "metadata_value": "Valoarea metadatelor", "missing_feedback_source_title": "Lipsește o sursă de feedback?", "no_feedback_directory_available": "Niciun set de date de feedback atribuit acestui workspace. Creează sau atribuie unul mai întâi.", "no_feedback_directory_linked_admin_description": "Seturile de date cu feedback reunesc datele de feedback din mai multe spații de lucru. Conectează un set de date la acest spațiu de lucru pentru a începe.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Selectează un chestionar pentru a vedea întrebările", "select_a_value": "Selectează o valoare...", "select_feedback_directory": "Selectează un set de date", - "select_feedback_record_source_type": "Selectați tipul sursei", "select_questions": "Selectează întrebări", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Selectează tipul sursei de feedback pe care vrei să o conectezi.", @@ -4095,8 +4086,7 @@ "value": "Valoare", "value_boolean": "Valoare (booleană)", "value_date": "Valoare (data)", - "value_id": "ID opțiune", - "value_id_hint": "ID-ul stabil al opțiunii selectate în chestionarul sursă. Menține această înregistrare conectată la opțiunea sa prin editări de etichete și limbi, fiind gestionat automat.", + "value_id": "ID valoare", "value_number": "Valoare (număr)", "value_text": "Valoare (Text)" }, diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 0b644d6d364f..483aca3b05bd 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Тональность", "field_label_sentiment_average": "Настроение: Среднее", "field_label_sentiment_score": "Оценка тональности", + "field_label_source_id": "ID источника", "field_label_source_name": "Название источника", "field_label_source_type": "Тип источника", "field_label_surprise_count": "Эмоция: Удивление", @@ -1759,6 +1760,7 @@ "no_data_returned": "Нет данных для отображения, измени настройки.", "no_data_returned_for_chart": "Для графика не получено данных", "no_data_source_available": "К этому рабочему пространству не привязан набор данных отзывов.", + "no_fields_found": "Поля не найдены", "no_grouping": "Нет (только фильтр)", "no_valid_data_to_display": "Нет корректных данных для отображения", "no_values_found": "Значения не найдены", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Сохранить и добавить на панель управления", "save_chart": "Сохранить график", "save_chart_dialog_title": "Сохранить график", + "search_field": "Поиск поля...", "search_value": "Поиск значения...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Посмотрите, какие команды имеют доступ к этому рабочему пространству." }, "unify": { - "add_feedback_record": "Добавить запись отзыва", - "add_feedback_record_description": "Создайте запись обратной связи вручную.", "add_feedback_source": "Добавить источник обратной связи", "add_source": "Добавить источник", "ai_enrichment": "Обогащение с помощью ИИ", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Указывает, откуда пришёл этот набор отзывов.", "csv_unmapped_columns": "Несопоставленные столбцы ({count}): {columns}", "csv_unmapped_columns_explainer": "Эти столбцы не используются ни одним полем записи отзывов. Они будут проигнорированы при импорте.", - "custom_source_type": "Пользовательский тип источника", - "custom_source_type_placeholder": "Введите собственный тип источника", "data_origin": "Data origin", "default_source_name_csv": "Импорт CSV", "default_source_name_formbricks": "Источник опросов Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Это действие безвозвратно удалит запись обратной связи и удалит её из подключенного датасета.", "delete_feedback_records_confirmation": "Это действие безвозвратно удалит {count} записей обратной связи и удалит их из подключенного датасета.", "delete_source_confirmation": "Удаление этого источника остановит будущие импорты и удалит сохранённое сопоставление. Существующие записи обратной связи останутся доступны.", - "discard_feedback_record_changes_description": "Ваши изменения будут потеряны, если вы закроете этот ящик.", - "discard_feedback_record_changes_title": "Отменить несохраненные изменения?", "dismiss": "Dismiss", "download_sample_csv": "Скачать пример CSV", "edit_csv_mapping": "Редактировать сопоставление CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Не удалось загрузить отзывы", "feedback_data": "Feedback Data", "feedback_directory": "Датасет обратной связи", - "feedback_record_created_successfully": "Запись отзыва успешно создана", "feedback_record_deleted_successfully": "Запись обратной связи успешно удалена", "feedback_record_details": "Детали записи обратной связи", "feedback_record_details_description": "Просмотрите и обновите поля записи отзыва.", "feedback_record_mcp": "Использовать MCP-сервер", - "feedback_record_updated_successfully": "Запись отзыва успешно обновлена.", - "feedback_record_value_required": "Требуется значение для выбранного типа поля.", "feedback_records": "Записи отзывов", "feedback_records_deleted_successfully": "Удалено {count} {count, plural, one {запись обратной связи} few {записи обратной связи} many {записей обратной связи} other {записей обратной связи}}", "feedback_records_partially_deleted": "Удалено {succeeded} из {total} записей обратной связи", @@ -3954,9 +3948,7 @@ "manage_directories": "Управление датасетами", "manage_feedback_sources": "Управление источниками обратной связи", "metadata": "Метаданные", - "metadata_key": "Ключ метаданных", "metadata_read_only_entries": "Значения метаданных только для чтения (нестроковые)", - "metadata_value": "Значение метаданных", "missing_feedback_source_title": "Не нашли нужный источник обратной связи?", "no_feedback_directory_available": "К этому рабочему пространству не привязан датасет обратной связи. Сначала создайте или назначьте его.", "no_feedback_directory_linked_admin_description": "Наборы данных обратной связи объединяют данные из нескольких рабочих пространств. Подключите набор данных к этому рабочему пространству, чтобы начать работу.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Выберите опрос, чтобы увидеть его вопросы", "select_a_value": "Выберите значение...", "select_feedback_directory": "Выбрать датасет", - "select_feedback_record_source_type": "Выберите тип источника", "select_questions": "Выберите вопросы", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Выберите тип источника отзывов, который хотите подключить.", @@ -4095,8 +4086,7 @@ "value": "Значение", "value_boolean": "Значение (логическое)", "value_date": "Значение (Дата)", - "value_id": "ID варианта", - "value_id_hint": "Стабильный ID выбранного варианта в исходном опросе. Он сохраняет связь этой записи с вариантом при изменении текста и языков и управляется автоматически.", + "value_id": "ID значения", "value_number": "Значение (число)", "value_text": "Значение (текст)" }, diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 7b267428cdcb..72a241e83ba8 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentiment", "field_label_sentiment_average": "Sentiment: Medelvärde", "field_label_sentiment_score": "Sentimentpoäng", + "field_label_source_id": "Käll-ID", "field_label_source_name": "Källnamn", "field_label_source_type": "Källtyp", "field_label_surprise_count": "Känsla: Överraskning", @@ -1759,6 +1760,7 @@ "no_data_returned": "Ingen data att visa, ändra dina inställningar.", "no_data_returned_for_chart": "Ingen data returnerades för diagrammet", "no_data_source_available": "Inget feedbackdataset är tilldelat den här arbetsytan.", + "no_fields_found": "Inga fält hittades", "no_grouping": "Ingen (endast filter)", "no_valid_data_to_display": "Ingen giltig data att visa", "no_values_found": "Inga värden hittades", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Spara och lägg till i instrumentpanelen", "save_chart": "Spara diagram", "save_chart_dialog_title": "Spara diagram", + "search_field": "Sök fält...", "search_value": "Sök värde...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Se vilka team som har tillgång till denna arbetsyta." }, "unify": { - "add_feedback_record": "Lägg till feedbackpost", - "add_feedback_record_description": "Skapa en feedbackpost manuellt.", "add_feedback_source": "Lägg till feedbackkälla", "add_source": "Lägg till källa", "ai_enrichment": "AI-berikning", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifierar var denna batch av feedback kom ifrån.", "csv_unmapped_columns": "Omappade kolumner ({count}): {columns}", "csv_unmapped_columns_explainer": "Dessa kolumner används inte av något fält i feedbackposten. De kommer att ignoreras vid import.", - "custom_source_type": "Anpassad källtyp", - "custom_source_type_placeholder": "Ange anpassad källtyp", "data_origin": "Data origin", "default_source_name_csv": "CSV-import", "default_source_name_formbricks": "Formbricks enkätkälla", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Detta kommer permanent radera feedbackposten och ta bort den från det anslutna datasetet.", "delete_feedback_records_confirmation": "Detta kommer permanent radera {count} feedbackposter och ta bort dem från det anslutna datasetet.", "delete_source_confirmation": "Att ta bort den här källan kommer att stoppa framtida importer och ta bort dess sparade mappning. Befintliga feedbackposter kommer att förbli tillgängliga.", - "discard_feedback_record_changes_description": "Dina ändringar kommer att gå förlorade om du stänger den här lådan.", - "discard_feedback_record_changes_title": "Vill du ignorera osparade ändringar?", "dismiss": "Dismiss", "download_sample_csv": "Ladda ner exempel-CSV", "edit_csv_mapping": "Redigera CSV-mappning", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter", "feedback_data": "Feedback Data", "feedback_directory": "Feedbackdataset", - "feedback_record_created_successfully": "Feedbackposten har skapats", "feedback_record_deleted_successfully": "Feedbackposten har tagits bort", "feedback_record_details": "Feedbackpostdetaljer", "feedback_record_details_description": "Granska och uppdatera fält för feedbackposter.", "feedback_record_mcp": "Använd MCP-server", - "feedback_record_updated_successfully": "Feedbackposten har uppdaterats", - "feedback_record_value_required": "Ett värde krävs för den valda fälttypen", "feedback_records": "Feedbackposter", "feedback_records_deleted_successfully": "{count} feedbackposter har tagits bort", "feedback_records_partially_deleted": "{succeeded} av {total} feedbackposter raderade", @@ -3954,9 +3948,7 @@ "manage_directories": "Hantera dataset", "manage_feedback_sources": "Hantera feedbackkällor", "metadata": "Metadata", - "metadata_key": "Metadatanyckel", "metadata_read_only_entries": "Skrivskyddade metadatavärden (icke-sträng)", - "metadata_value": "Metadatavärde", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_directory_available": "Inget feedbackdataset är tilldelat denna arbetsyta. Skapa eller tilldela ett först.", "no_feedback_directory_linked_admin_description": "Feedbackdataset samlar feedbackdata från flera arbetsytor. Anslut ett dataset till den här arbetsytan för att komma igång.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Välj en enkät för att se dess frågor", "select_a_value": "Välj ett värde...", "select_feedback_directory": "Välj ett dataset", - "select_feedback_record_source_type": "Välj källtyp", "select_questions": "Välj frågor", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Välj vilken typ av feedbackkälla du vill ansluta.", @@ -4095,8 +4086,7 @@ "value": "Värde", "value_boolean": "Värde (booleskt)", "value_date": "Värde (datum)", - "value_id": "Alternativ-ID", - "value_id_hint": "Stabilt ID för det valda alternativet i källenkäten. Det håller denna post länkad till sitt alternativ över etikettändringar och språk, och hanteras automatiskt.", + "value_id": "Värde-ID", "value_number": "Värde (antal)", "value_text": "Värde (text)" }, diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index f6ca1abe409f..5a430534db24 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Duygu Durumu", "field_label_sentiment_average": "Duyarlılık: Ortalama", "field_label_sentiment_score": "Duygu Durumu Puanı", + "field_label_source_id": "Kaynak Kimliği", "field_label_source_name": "Kaynak Adı", "field_label_source_type": "Kaynak Türü", "field_label_surprise_count": "Duygu: Şaşkınlık", @@ -1759,6 +1760,7 @@ "no_data_returned": "Görüntülenecek veri yok, lütfen ayarlarını değiştir.", "no_data_returned_for_chart": "Grafik için veri döndürülmedi", "no_data_source_available": "Bu çalışma alanına atanmış bir geri bildirim veri kümesi yok.", + "no_fields_found": "Alan bulunamadı", "no_grouping": "Yok (sadece filtre)", "no_valid_data_to_display": "Görüntülenecek geçerli veri yok", "no_values_found": "Değer bulunamadı", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Kaydet ve kontrol paneline ekle", "save_chart": "Grafiği Kaydet", "save_chart_dialog_title": "Grafiği Kaydet", + "search_field": "Alan ara...", "search_value": "Değer ara...", "select_data_source": "Bir veri kaynağı seç", "select_data_source_first": "Lütfen önce bir veri kaynağı seç", @@ -3835,8 +3838,6 @@ "team_settings_description": "Bu çalışma alanına hangi takımların erişebildiğini görün." }, "unify": { - "add_feedback_record": "Geri bildirim kaydı ekle", - "add_feedback_record_description": "Manuel olarak bir geri bildirim kaydı oluşturun.", "add_feedback_source": "Geri bildirim kaynağı ekle", "add_source": "Kaynak ekle", "ai_enrichment": "AI zenginleştirme", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Bu geri bildirim grubunun nereden geldiğini tanımlar.", "csv_unmapped_columns": "Eşlenmemiş sütunlar ({count}): {columns}", "csv_unmapped_columns_explainer": "Bu sütunlar herhangi bir Geri Bildirim Kaydı alanı tarafından kullanılmıyor. İçe aktarma sırasında göz ardı edilecekler.", - "custom_source_type": "Özel kaynak türü", - "custom_source_type_placeholder": "Özel kaynak türünü girin", "data_origin": "Data origin", "default_source_name_csv": "CSV İçe Aktarma", "default_source_name_formbricks": "Formbricks Anket Kaynağı", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Bu işlem geri bildirim kaydını kalıcı olarak silecek ve bağlı veri kümesinden kaldıracak.", "delete_feedback_records_confirmation": "Bu işlem {count} geri bildirim kaydını kalıcı olarak silecek ve bunları bağlı veri kümesinden kaldıracak.", "delete_source_confirmation": "Bu kaynağı silmek, gelecekteki içe aktarmaları durduracak ve kayıtlı eşleştirmesini kaldıracak. Mevcut geri bildirim kayıtları erişilebilir durumda kalacak.", - "discard_feedback_record_changes_description": "Bu çekmeceyi kapatırsanız değişiklikleriniz kaybolacak.", - "discard_feedback_record_changes_title": "Kaydedilmemiş değişiklikler silinsin mi?", "dismiss": "Dismiss", "download_sample_csv": "Örnek CSV indir", "edit_csv_mapping": "CSV eşlemesini düzenle", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi", "feedback_data": "Feedback Data", "feedback_directory": "Geri Bildirim Veri Kümesi", - "feedback_record_created_successfully": "Geri bildirim kaydı başarıyla oluşturuldu", "feedback_record_deleted_successfully": "Geri bildirim kaydı başarıyla silindi", "feedback_record_details": "Geri bildirim kaydı ayrıntıları", "feedback_record_details_description": "Geri bildirim kayıt alanlarını inceleyin ve güncelleyin.", "feedback_record_mcp": "MCP sunucusunu kullan", - "feedback_record_updated_successfully": "Geri bildirim kaydı başarıyla güncellendi", - "feedback_record_value_required": "Seçilen alan türü için bir değer gerekli", "feedback_records": "Geri Bildirim Kayıtları", "feedback_records_deleted_successfully": "{count} geri bildirim kaydı silindi", "feedback_records_partially_deleted": "{total} geri bildirim kaydından {succeeded} tanesi silindi", @@ -3954,9 +3948,7 @@ "manage_directories": "Veri kümelerini yönet", "manage_feedback_sources": "Geri bildirim kaynaklarını yönetin", "metadata": "Meta veriler", - "metadata_key": "Meta veri anahtarı", "metadata_read_only_entries": "Salt okunur meta veri değerleri (dize dışı)", - "metadata_value": "Meta veri değeri", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_directory_available": "Bu çalışma alanına atanmış geri bildirim veri kümesi yok. Önce bir tane oluştur veya ata.", "no_feedback_directory_linked_admin_description": "Geri bildirim veri kümeleri, birden fazla çalışma alanından gelen geri bildirim verilerini bir araya getirir. Başlamak için bu çalışma alanına bir veri kümesi bağla.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Sorularını görmek için bir anket seç", "select_a_value": "Bir değer seç...", "select_feedback_directory": "Bir veri kümesi seç", - "select_feedback_record_source_type": "Kaynak türünü seçin", "select_questions": "Soru seç", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Bağlamak istediğin geri bildirim kaynağının türünü seç.", @@ -4095,8 +4086,7 @@ "value": "Değer", "value_boolean": "Değer (Boolean)", "value_date": "Değer (Tarih)", - "value_id": "Seçenek ID", - "value_id_hint": "Kaynak anketteki seçilen seçeneğin sabit ID'si. Bu kayıt, etiket düzenlemeleri ve diller genelinde seçeneğiyle bağlantılı kalmasını sağlar ve otomatik olarak yönetilir.", + "value_id": "Değer Kimliği", "value_number": "Değer (Sayı)", "value_text": "Değer (Metin)" }, diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index e69bd76bf035..e665b1d8cc9a 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "情感", "field_label_sentiment_average": "情感:平均值", "field_label_sentiment_score": "情感得分", + "field_label_source_id": "来源 ID", "field_label_source_name": "来源名称", "field_label_source_type": "来源类型", "field_label_surprise_count": "情绪:惊讶", @@ -1759,6 +1760,7 @@ "no_data_returned": "没有可显示的数据,请更改你的设置。", "no_data_returned_for_chart": "该图表未返回数据", "no_data_source_available": "此工作区尚未分配反馈数据集。", + "no_fields_found": "未找到字段", "no_grouping": "无(仅筛选)", "no_valid_data_to_display": "无有效数据可显示", "no_values_found": "未找到任何值", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "保存并添加到仪表板", "save_chart": "保存图表", "save_chart_dialog_title": "保存图表", + "search_field": "搜索字段...", "search_value": "搜索值...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "查看哪些团队可以访问此工作区。" }, "unify": { - "add_feedback_record": "添加反馈记录", - "add_feedback_record_description": "手动创建反馈记录。", "add_feedback_source": "添加反馈来源", "add_source": "添加来源", "ai_enrichment": "AI 丰富", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "标识这批反馈的来源。", "csv_unmapped_columns": "未映射列({count}):{columns}", "csv_unmapped_columns_explainer": "这些列未被任何反馈记录字段使用,导入时将被忽略。", - "custom_source_type": "自定义源类型", - "custom_source_type_placeholder": "输入自定义来源类型", "data_origin": "Data origin", "default_source_name_csv": "CSV 导入", "default_source_name_formbricks": "Formbricks 调研来源", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "此操作将永久删除该反馈记录,并将其从关联的数据集中移除。", "delete_feedback_records_confirmation": "此操作将永久删除 {count} 条反馈记录,并将它们从关联的数据集中移除。", "delete_source_confirmation": "删除此数据源将停止未来的导入并移除其保存的映射。现有的反馈记录将保持可用。", - "discard_feedback_record_changes_description": "如果关闭此抽屉,您的更改将会丢失。", - "discard_feedback_record_changes_title": "放弃未保存的更改?", "dismiss": "Dismiss", "download_sample_csv": "下载示例 CSV", "edit_csv_mapping": "编辑 CSV 映射", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "加载反馈记录失败", "feedback_data": "Feedback Data", "feedback_directory": "反馈数据集", - "feedback_record_created_successfully": "反馈记录创建成功", "feedback_record_deleted_successfully": "反馈记录已成功删除", "feedback_record_details": "反馈记录详情", "feedback_record_details_description": "查看并更新反馈记录字段。", "feedback_record_mcp": "使用 MCP 服务器", - "feedback_record_updated_successfully": "反馈记录更新成功", - "feedback_record_value_required": "所选字段类型需要一个值", "feedback_records": "反馈记录", "feedback_records_deleted_successfully": "已删除 {count} 条反馈记录", "feedback_records_partially_deleted": "已删除 {succeeded} 条(共 {total} 条)反馈记录", @@ -3954,9 +3948,7 @@ "manage_directories": "管理数据集", "manage_feedback_sources": "管理反馈来源", "metadata": "元数据", - "metadata_key": "元数据键", "metadata_read_only_entries": "只读元数据值(非字符串)", - "metadata_value": "元数据值", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_directory_available": "此工作区未分配反馈数据集。请先创建或分配一个。", "no_feedback_directory_linked_admin_description": "反馈数据集将来自多个工作区的反馈数据汇集在一起。将数据集连接到此工作区即可开始使用。", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "请选择一个调查以查看其问题", "select_a_value": "选择一个值...", "select_feedback_directory": "选择数据集", - "select_feedback_record_source_type": "选择来源类型", "select_questions": "选择问题", "select_questions_for_import": "Select questions for import", "select_source_type_description": "请选择你想要连接的反馈来源类型。", @@ -4095,8 +4086,7 @@ "value": "值", "value_boolean": "值(布尔值)", "value_date": "值(日期)", - "value_id": "选项 ID", - "value_id_hint": "源调查问卷中所选选项的稳定 ID。它使此记录在标签编辑和语言变更时保持与其选项的关联,并且是自动管理的。", + "value_id": "值 ID", "value_number": "值(数量)", "value_text": "值(文本)" }, diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index f254e4363703..6a27005bb83d 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "情感", "field_label_sentiment_average": "情感:平均值", "field_label_sentiment_score": "情感分數", + "field_label_source_id": "來源 ID", "field_label_source_name": "來源名稱", "field_label_source_type": "來源類型", "field_label_surprise_count": "情緒:驚訝", @@ -1759,6 +1760,7 @@ "no_data_returned": "沒有資料可顯示,請更改你的設定。", "no_data_returned_for_chart": "此圖表沒有回傳資料", "no_data_source_available": "此工作區未指派任何意見回饋資料集。", + "no_fields_found": "找不到欄位", "no_grouping": "無(僅篩選)", "no_valid_data_to_display": "沒有可顯示的有效資料", "no_values_found": "找不到值", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "儲存並新增到儀表板", "save_chart": "儲存圖表", "save_chart_dialog_title": "儲存圖表", + "search_field": "搜尋欄位...", "search_value": "搜尋值...", "select_data_source": "選擇資料來源", "select_data_source_first": "請先選擇資料來源", @@ -3835,8 +3838,6 @@ "team_settings_description": "查看哪些團隊可以存取此工作區。" }, "unify": { - "add_feedback_record": "新增回饋記錄", - "add_feedback_record_description": "手動建立回饋記錄。", "add_feedback_source": "新增意見回饋來源", "add_source": "新增來源", "ai_enrichment": "AI 擴充", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "識別這批意見回饋的來源。", "csv_unmapped_columns": "未對應的欄位({count} 個):{columns}", "csv_unmapped_columns_explainer": "這些欄位沒有被任何意見回饋記錄欄位使用。匯入時會被忽略。", - "custom_source_type": "自訂來源類型", - "custom_source_type_placeholder": "輸入自訂來源類型", "data_origin": "Data origin", "default_source_name_csv": "CSV 匯入", "default_source_name_formbricks": "Formbricks 問卷來源", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "這將永久刪除該意見回饋記錄,並從已連結的資料集中移除。", "delete_feedback_records_confirmation": "這將永久刪除 {count} 筆意見回饋記錄,並從已連結的資料集中移除。", "delete_source_confirmation": "刪除此來源將停止未來的匯入並移除其已儲存的對應設定。現有的意見回饋記錄將保持可用。", - "discard_feedback_record_changes_description": "如果關閉此抽屜,您的變更將會遺失。", - "discard_feedback_record_changes_title": "放棄未儲存的變更?", "dismiss": "Dismiss", "download_sample_csv": "下載範例 CSV", "edit_csv_mapping": "編輯 CSV 對應", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "載入回饋紀錄失敗", "feedback_data": "Feedback Data", "feedback_directory": "意見回饋資料集", - "feedback_record_created_successfully": "回饋記錄創建成功", "feedback_record_deleted_successfully": "意見回饋記錄已成功刪除", "feedback_record_details": "反饋記錄詳情", "feedback_record_details_description": "查看並更新回饋記錄欄位。", "feedback_record_mcp": "使用 MCP 伺服器", - "feedback_record_updated_successfully": "回饋記錄更新成功", - "feedback_record_value_required": "所選欄位類型需要一個值", "feedback_records": "回饋紀錄", "feedback_records_deleted_successfully": "已刪除 {count} 筆意見回饋記錄", "feedback_records_partially_deleted": "已刪除 {succeeded} 筆意見回饋記錄,共 {total} 筆", @@ -3954,9 +3948,7 @@ "manage_directories": "管理資料集", "manage_feedback_sources": "管理回饋來源", "metadata": "中繼資料", - "metadata_key": "元資料鍵", "metadata_read_only_entries": "唯讀元資料值(非字串)", - "metadata_value": "元資料值", "missing_feedback_source_title": "缺少意見回饋來源?", "no_feedback_directory_available": "此工作區尚未指派意見回饋資料集。請先建立或指派一個。", "no_feedback_directory_linked_admin_description": "意見回饋資料集將來自多個工作區的意見回饋資料整合在一起。將資料集連結到此工作區即可開始使用。", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "請選擇問卷以查看其問題", "select_a_value": "請選擇一個值...", "select_feedback_directory": "選擇資料集", - "select_feedback_record_source_type": "選擇來源類型", "select_questions": "選擇問題", "select_questions_for_import": "Select questions for import", "select_source_type_description": "請選擇你想要連接的回饋來源類型。", @@ -4095,8 +4086,7 @@ "value": "值", "value_boolean": "值(布林值)", "value_date": "值(日期)", - "value_id": "選項 ID", - "value_id_hint": "來源問卷中所選選項的穩定 ID。它讓此記錄在標籤編輯和語言變更時保持與選項的連結,並會自動管理。", + "value_id": "值 ID", "value_number": "值(數量)", "value_text": "值(文字)" }, diff --git a/apps/web/modules/ee/analysis/charts/actions.test.ts b/apps/web/modules/ee/analysis/charts/actions.test.ts index 6bd56c93ed46..46af5658ff00 100644 --- a/apps/web/modules/ee/analysis/charts/actions.test.ts +++ b/apps/web/modules/ee/analysis/charts/actions.test.ts @@ -14,6 +14,9 @@ const mocks = vi.hoisted(() => { executeTenantScopedQuery: vi.fn(), generateAIChartQuery: vi.fn(), updateChart: vi.fn(), + getFeedbackSourcesWithMappings: vi.fn(), + getSurvey: vi.fn(), + getElementsFromBlocks: vi.fn(), }; }); @@ -59,6 +62,18 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsDashboardsEnabled: mocks.getIsDashboardsEnabled, })); +vi.mock("@/lib/feedback-source/service", () => ({ + getFeedbackSourcesWithMappings: mocks.getFeedbackSourcesWithMappings, +})); + +// stub server-only modules pulled in by resolveOptionGrouping helpers +vi.mock("@/lib/survey/service", () => ({ getSurvey: mocks.getSurvey })); +vi.mock("@/lib/survey/utils", () => ({ getElementsFromBlocks: mocks.getElementsFromBlocks })); +vi.mock("@formbricks/types/surveys/validation", () => ({ getTextContent: (s: string) => s })); +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: (obj: Record, lang: string) => obj[lang] ?? obj["default"] ?? "", +})); + vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn), })); @@ -89,6 +104,9 @@ describe("chart Cube actions", () => { config: {}, }); mocks.executeTenantScopedQuery.mockResolvedValue([{ "FeedbackRecords.count": 1 }]); + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([]); + mocks.getSurvey.mockResolvedValue(null); + mocks.getElementsFromBlocks.mockReturnValue([]); mocks.updateChart.mockResolvedValue({ chart: { id: "chart-1", query: { measures: ["FeedbackRecords.count"] } }, updatedChart: { id: "chart-1", query: { measures: ["FeedbackRecords.count"] } }, @@ -107,7 +125,8 @@ describe("chart Cube actions", () => { parsedInput: { workspaceId: "workspace-1", query, feedbackDirectoryId: "frd-1" }, } as any); - expect(result).toEqual([{ "FeedbackRecords.count": 1 }]); + // executeQueryAction now returns { rows, optionLabels?, effectiveQuery } instead of raw array. + expect(result).toMatchObject({ rows: [{ "FeedbackRecords.count": 1 }] }); expect(mocks.checkWorkspaceAccess).toHaveBeenCalledWith("user-1", "workspace-1", "read"); expect(mocks.checkFeedbackDirectoryAccess).toHaveBeenCalledWith({ feedbackDirectoryId: "frd-1", @@ -223,4 +242,414 @@ describe("chart Cube actions", () => { expect(mocks.executeTenantScopedQuery).not.toHaveBeenCalled(); }); + + // ── Multi-select (MultipleChoiceMulti) detection and splitting ────────────── + + test("executeQueryAction keeps the user's dimension and returns optionLabels for a MultipleChoiceMulti element (no rewrite, no split)", async () => { + // Wire up feedbackSources -> survey -> MultipleChoiceMulti element. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-multi", surveyId: "survey-multi" }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-multi", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-multi", + type: "multipleChoiceMulti", + choices: [ + { id: "c1", label: { default: "One" } }, + { id: "c2", label: { default: "Two" } }, + ], + }, + ]); + + const query = { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + filters: [{ member: "FeedbackRecords.fieldId", operator: "equals", values: ["field-multi"] }], + }; + + // Multi-select now stores one record per option (see transform.ts), so rows arrive already + // per-option — no joined string to split. + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueText": "One", "FeedbackRecords.count": 8 }, + { "FeedbackRecords.valueText": "Two", "FeedbackRecords.count": 5 }, + ]); + + const result = await executeQueryAction({ + ctx, + parsedInput: { workspaceId: "workspace-1", query, feedbackDirectoryId: "frd-1" }, + } as any); + + // The query sent to Cube keeps the user's chosen dimension — no swap. + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueText"] }), + }) + ); + + // Rows pass through unchanged (no server-side splitting). + expect(result?.rows).toHaveLength(2); + // optionLabels is returned so a valueId grouping of the same question reads nicely. + expect(result?.optionLabels).toEqual({ c1: "One", c2: "Two" }); + }); + + test("executeQueryAction passes rows through unchanged and adds no labels for a non-choice element", async () => { + // A non-choice element type — OpenText — must not be touched. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-open", surveyId: "survey-open" }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-open", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([{ id: "field-open", type: "openText" }]); + + const rawRows = [{ "FeedbackRecords.valueText": "Hello, World", "FeedbackRecords.count": 2 }]; + mocks.executeTenantScopedQuery.mockResolvedValue(rawRows); + + const result = await executeQueryAction({ + ctx, + parsedInput: { + workspaceId: "workspace-1", + query: { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + filters: [{ member: "FeedbackRecords.fieldId", operator: "equals", values: ["field-open"] }], + }, + feedbackDirectoryId: "frd-1", + }, + } as any); + + expect(result?.rows).toHaveLength(1); + expect(result?.rows?.[0]?.["FeedbackRecords.valueText"]).toBe("Hello, World"); + expect(result).not.toHaveProperty("optionLabels"); + }); + + // ── fieldLabel filter fallback (ENG-1673) ──────────────────────────────────── + + test("executeQueryAction keeps the user's dimension (no valueText → valueId swap) and still returns optionLabels for a single-select matched by fieldLabel", async () => { + // The mapping has no customFieldLabel, so the effective label comes from the element headline. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-sc", surveyId: "survey-sc", customFieldLabel: null }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-sc", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-sc", + type: "multipleChoiceSingle", + headline: { default: "Favourite colour?" }, + choices: [ + { id: "c1", label: { default: "Red" } }, + { id: "c2", label: { default: "Blue" } }, + ], + }, + ]); + + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueText": "Red", "FeedbackRecords.count": 10 }, + { "FeedbackRecords.valueText": "Blue", "FeedbackRecords.count": 5 }, + ]); + + const result = await executeQueryAction({ + ctx, + parsedInput: { + workspaceId: "workspace-1", + query: { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + // User filtered by "Question" (fieldLabel), not by fieldId. + filters: [ + { member: "FeedbackRecords.fieldLabel", operator: "equals", values: ["Favourite colour?"] }, + ], + }, + feedbackDirectoryId: "frd-1", + }, + } as any); + + // The query sent to Cube must preserve the user's chosen dimension — no silent swap to valueId. + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueText"] }), + }) + ); + + // optionLabels is still returned so a valueId grouping can map ids to human labels. + expect(result?.optionLabels).toEqual({ c1: "Red", c2: "Blue" }); + }); + + test("executeQueryAction returns optionLabels for a multi-select element matched by fieldLabel (no rewrite/split)", async () => { + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-mc", surveyId: "survey-mc", customFieldLabel: null }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-mc", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-mc", + type: "multipleChoiceMulti", + headline: { default: "Pick all that apply" }, + choices: [ + { id: "opt1", label: { default: "A" } }, + { id: "opt2", label: { default: "B" } }, + ], + }, + ]); + + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueText": "A", "FeedbackRecords.count": 5 }, + { "FeedbackRecords.valueText": "B", "FeedbackRecords.count": 3 }, + ]); + + const result = await executeQueryAction({ + ctx, + parsedInput: { + workspaceId: "workspace-1", + query: { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + filters: [ + { member: "FeedbackRecords.fieldLabel", operator: "equals", values: ["Pick all that apply"] }, + ], + }, + feedbackDirectoryId: "frd-1", + }, + } as any); + + // Dimension preserved (no swap), rows pass through unchanged. + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueText"] }), + }) + ); + expect(result?.rows).toHaveLength(2); + expect(result?.optionLabels).toEqual({ opt1: "A", opt2: "B" }); + }); + + test("executeQueryAction does NOT rewrite when fieldLabel matches multiple mappings (ambiguous)", async () => { + // Two different mappings share the same effective label — must not guess. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [ + { elementId: "field-a", surveyId: "survey-a", customFieldLabel: null }, + { elementId: "field-b", surveyId: "survey-b", customFieldLabel: null }, + ], + }, + ]); + // Both surveys return an element with the same headline. + mocks.getSurvey.mockImplementation((id: string) => Promise.resolve({ id, blocks: [] })); + mocks.getElementsFromBlocks.mockImplementation((blocks: unknown[]) => { + // Return an element whose headline matches; the elementId will vary per survey. + // We use a stable element id here because getElementsFromBlocks is mocked globally, + // but the key point is that both mappings resolve to the same label. + return [ + { + id: "field-a", + type: "multipleChoiceSingle", + headline: { default: "Duplicate label" }, + choices: [], + }, + { + id: "field-b", + type: "multipleChoiceSingle", + headline: { default: "Duplicate label" }, + choices: [], + }, + ]; + }); + + const rawRows = [{ "FeedbackRecords.valueText": "X", "FeedbackRecords.count": 1 }]; + mocks.executeTenantScopedQuery.mockResolvedValue(rawRows); + + const result = await executeQueryAction({ + ctx, + parsedInput: { + workspaceId: "workspace-1", + query: { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + filters: [ + { member: "FeedbackRecords.fieldLabel", operator: "equals", values: ["Duplicate label"] }, + ], + }, + feedbackDirectoryId: "frd-1", + }, + } as any); + + // Cube must be called with the ORIGINAL (unrewritten) query. + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueText"] }), + }) + ); + + // No optionLabels returned. + expect(result).not.toHaveProperty("optionLabels"); + }); + + // ── New: dimension=valueId paths (FIX A + FIX B) ──────────────────────────── + + test("executeQueryAction returns effectiveQuery equal to original for a plain count query (no value dimension)", async () => { + const query = { measures: ["FeedbackRecords.count"] }; + mocks.executeTenantScopedQuery.mockResolvedValue([{ "FeedbackRecords.count": 42 }]); + + const result = await executeQueryAction({ + ctx, + parsedInput: { workspaceId: "workspace-1", query, feedbackDirectoryId: "frd-1" }, + } as any); + + expect(result?.effectiveQuery).toEqual(query); + expect(result).not.toHaveProperty("optionLabels"); + }); + + test("executeQueryAction keeps valueId dimension and returns optionLabels for a single-select queried directly by valueId + fieldId filter", async () => { + // User selected "Value (Option)" directly from the picker — dimension is already valueId. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-sc3", surveyId: "survey-sc3" }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-sc3", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-sc3", + type: "multipleChoiceSingle", + headline: { default: "Size?" }, + choices: [ + { id: "s", label: { default: "Small" } }, + { id: "l", label: { default: "Large" } }, + ], + }, + ]); + + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueId": "s", "FeedbackRecords.count": 6 }, + { "FeedbackRecords.valueId": "l", "FeedbackRecords.count": 4 }, + ]); + + const query = { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueId"], + filters: [{ member: "FeedbackRecords.fieldId", operator: "equals", values: ["field-sc3"] }], + }; + + const result = await executeQueryAction({ + ctx, + parsedInput: { workspaceId: "workspace-1", query, feedbackDirectoryId: "frd-1" }, + } as any); + + // Cube must receive valueId (no rewrite needed — already the right dimension). + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueId"] }), + }) + ); + + // effectiveQuery matches the original (no dimension swap required). + expect(result?.effectiveQuery).toMatchObject({ dimensions: ["FeedbackRecords.valueId"] }); + + // optionLabels must be present so the renderer can map ids to labels. + expect(result?.optionLabels).toEqual({ s: "Small", l: "Large" }); + }); + + test("executeQueryAction keeps valueId (no swap) and returns optionLabels for a multi-select queried by valueId + fieldId filter", async () => { + // User selected "Value (Option)" for a multi-select field — it must stay valueId, not pop back + // to Value (Text). Multi-select stores one record per option with its own value_id now. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-mc2", surveyId: "survey-mc2" }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-mc2", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-mc2", + type: "multipleChoiceMulti", + headline: { default: "Interests?" }, + choices: [ + { id: "opt-a", label: { default: "Music" } }, + { id: "opt-b", label: { default: "Sport" } }, + ], + }, + ]); + + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueId": "opt-a", "FeedbackRecords.count": 10 }, + { "FeedbackRecords.valueId": "opt-b", "FeedbackRecords.count": 7 }, + ]); + + const query = { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueId"], + filters: [{ member: "FeedbackRecords.fieldId", operator: "equals", values: ["field-mc2"] }], + }; + + const result = await executeQueryAction({ + ctx, + parsedInput: { workspaceId: "workspace-1", query, feedbackDirectoryId: "frd-1" }, + } as any); + + // Cube must have been called with valueId — no swap back to valueText. + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueId"] }), + }) + ); + + // effectiveQuery stays on valueId (this is what the builder reflects back — no auto-pop). + expect(result?.effectiveQuery).toMatchObject({ dimensions: ["FeedbackRecords.valueId"] }); + + // optionLabels map the ids to human labels for the renderer. + expect(result?.optionLabels).toEqual({ "opt-a": "Music", "opt-b": "Sport" }); + }); + + test("executeQueryAction uses fieldId filter when both fieldId and fieldLabel filters are present", async () => { + // fieldId should take precedence; fieldLabel is ignored. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-sc2", surveyId: "survey-sc2", customFieldLabel: null }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-sc2", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-sc2", + type: "multipleChoiceSingle", + headline: { default: "Colour?" }, + choices: [{ id: "cx1", label: { default: "Green" } }], + }, + ]); + + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueText": "Green", "FeedbackRecords.count": 7 }, + ]); + + const result = await executeQueryAction({ + ctx, + parsedInput: { + workspaceId: "workspace-1", + query: { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + filters: [ + { member: "FeedbackRecords.fieldId", operator: "equals", values: ["field-sc2"] }, + { member: "FeedbackRecords.fieldLabel", operator: "equals", values: ["Colour?"] }, + ], + }, + feedbackDirectoryId: "frd-1", + }, + } as any); + + // Dimension is preserved (no swap); the returned optionLabels confirm the element was + // resolved via the fieldId filter (its mapping is the only source that was mocked). + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueText"] }), + }) + ); + expect(result?.optionLabels).toEqual({ cx1: "Green" }); + }); }); diff --git a/apps/web/modules/ee/analysis/charts/actions.ts b/apps/web/modules/ee/analysis/charts/actions.ts index 5940381e6db4..d7146ead2c69 100644 --- a/apps/web/modules/ee/analysis/charts/actions.ts +++ b/apps/web/modules/ee/analysis/charts/actions.ts @@ -17,6 +17,7 @@ import { getCharts, updateChart, } from "@/modules/ee/analysis/charts/lib/charts"; +import { resolveOptionGrouping } from "@/modules/ee/analysis/charts/lib/option-grouping"; import { checkFeedbackDirectoryAccess, checkWorkspaceAccess } from "@/modules/ee/analysis/lib/access"; import { isSelectableValueDimension } from "@/modules/ee/analysis/lib/schema-definition"; import { ZChartCreateInput, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis"; @@ -278,14 +279,20 @@ export const executeQueryAction = authenticatedActionClient source: "charts.executeQueryAction", }); - return executeTenantScopedQuery({ - query: parsedInput.query, + const { rewrittenQuery, optionLabels } = await resolveOptionGrouping(parsedInput.query, workspaceId); + + const rawRows = await executeTenantScopedQuery({ + query: rewrittenQuery, feedbackDirectoryId, workspaceId, organizationId, userId: ctx.user.id, source: "charts.executeQueryAction", }); + + const rows = Array.isArray(rawRows) ? rawRows : []; + + return { rows, ...(optionLabels ? { optionLabels } : {}), effectiveQuery: rewrittenQuery }; } ); @@ -363,7 +370,8 @@ const ZGetDimensionValuesAction = z.object({ /** * Returns the distinct stored values for a low-cardinality string dimension, so the * filter UI can offer a pick-list instead of free-text entry. Picking a real value - * guarantees an exact match for the `equals` / `notEquals` operators. + * guarantees an exact match for the `equals` / `notEquals` operators. Search narrows + * results server-side so dimensions with more than the lookup cap stay usable. */ export const getDimensionValuesAction = authenticatedActionClient .inputSchema(ZGetDimensionValuesAction) @@ -374,7 +382,7 @@ export const getDimensionValuesAction = authenticatedActionClient }: { ctx: AuthenticatedActionClientCtx; parsedInput: z.infer; - }) => { + }): Promise => { const { organizationId, workspaceId } = await checkWorkspaceAccess( ctx.user.id, parsedInput.workspaceId, diff --git a/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx b/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx index 7c5eb71c4152..0bda2c60e236 100644 --- a/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx +++ b/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx @@ -183,6 +183,43 @@ export function AdvancedChartBuilder({ />
+ { + if (filtersOpen) { + dispatch({ type: ACTION.SET_FILTERS, payload: [] }); + } else if (state.filters.length === 0) { + const firstField = FEEDBACK_FIELDS.dimensions[0] ?? FEEDBACK_FIELDS.measures[0]; + dispatch({ + type: ACTION.SET_FILTERS, + payload: [ + { + id: crypto.randomUUID(), + field: firstField?.id ?? "", + operator: "equals" as const, + values: null, + }, + ], + }); + } + }} + htmlId="chart-filters-toggle" + title={t("workspace.analysis.charts.filter_data")} + description={t("workspace.analysis.charts.filters_toggle_description")} + customContainerClass="mt-2 px-0" + childrenContainerClass="flex-col gap-3 p-4" + childBorder> + dispatch({ type: ACTION.SET_FILTERS, payload: filters })} + onFilterLogicChange={(logic) => dispatch({ type: ACTION.SET_FILTER_LOGIC, payload: logic })} + /> + + { @@ -228,43 +265,6 @@ export function AdvancedChartBuilder({ onTimeDimensionChange={(config) => dispatch({ type: ACTION.SET_TIME_DIMENSION, payload: config })} /> - - { - if (filtersOpen) { - dispatch({ type: ACTION.SET_FILTERS, payload: [] }); - } else if (state.filters.length === 0) { - const firstField = FEEDBACK_FIELDS.dimensions[0] ?? FEEDBACK_FIELDS.measures[0]; - dispatch({ - type: ACTION.SET_FILTERS, - payload: [ - { - id: crypto.randomUUID(), - field: firstField?.id ?? "", - operator: "equals" as const, - values: null, - }, - ], - }); - } - }} - htmlId="chart-filters-toggle" - title={t("workspace.analysis.charts.filter_data")} - description={t("workspace.analysis.charts.filters_toggle_description")} - customContainerClass="mt-2 px-0" - childrenContainerClass="flex-col gap-3 p-4" - childBorder> - dispatch({ type: ACTION.SET_FILTERS, payload: filters })} - onFilterLogicChange={(logic) => dispatch({ type: ACTION.SET_FILTER_LOGIC, payload: logic })} - /> -
); } diff --git a/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx b/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx index 2613ee804f35..eaa9139076e9 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx @@ -82,12 +82,17 @@ export function ChartPreview({ - + - + ); diff --git a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx index 137983655e85..88c673851746 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx @@ -9,6 +9,8 @@ import { PolishedChartTooltip } from "@/modules/ee/analysis/charts/components/po import { CHART_BRAND_DARK, CHART_MEASURE_COLORS, + PIE_MEASURE_NAME_KEY, + PIE_MEASURE_VALUE_KEY, PIVOTED_MEASURE_KEY, PIVOTED_VALUE_KEY, formatCellValue, @@ -16,6 +18,7 @@ import { getSemanticDimensionColor, getSentimentMeasureColor, pivotMeasuresToCategories, + prepareMeasureSliceData, preparePieData, } from "@/modules/ee/analysis/charts/lib/chart-utils"; import { @@ -120,13 +123,205 @@ const PieCenterLabel = ({ ); }; +interface BarChartViewProps { + sortedData: TChartDataRow[]; + dataKeys: string[]; + isMultiMeasure: boolean; + hasCategoryAxis: boolean; + xAxisKey: string; + chartConfig: ChartConfig; + formatDimensionValue: (value: unknown) => string; +} + +const BarChartView = ({ + sortedData, + dataKeys, + isMultiMeasure, + hasCategoryAxis, + xAxisKey, + chartConfig, + formatDimensionValue, +}: Readonly) => { + const { t } = useTranslation(); + + // Measure-only queries (no dimension or time grouping) return a single row with one + // column per measure. Rendered as N bar series that row forms a single category band + // that recharts centers in the plot, leaving a wide empty gap before the first bar + // (ENG-1796). Pivot the measures into categories so the bars fill the axis from the + // left like any other category bar chart, labelled by measure on the x-axis. + if (!hasCategoryAxis) { + // Sentiment measures pivot into the scale order the sentiment *dimension* axis uses, + // so this chart and a dimension-grouped one read in the same direction. + const axisKeys = sortMeasureIdsForCategoryAxis(dataKeys); + const measureData = pivotMeasuresToCategories(sortedData, axisKeys, (key) => + formatCubeColumnHeader(key, t) + ); + // Ticks use the short value label ("Very positive") — the full measure label is too + // wide, so recharts would thin the ticks and bars would lose their name. The tooltip + // keeps the full label via tooltipLabel. + const formatMeasureLabel = (value: unknown) => + getMeasureAxisLabel(typeof value === "string" ? value : "", t); + return ( + + + formatCellValue(value)} + /> + + + ); + } + + // Setting `fill` on the data row (not via ) is what propagates + // the per-bar colour into the tooltip payload as well as the SVG. + const barData = isMultiMeasure + ? sortedData + : sortedData.map((row, index) => ({ + ...row, + fill: + getSemanticDimensionColor(xAxisKey, row[xAxisKey]) ?? + CHART_MEASURE_COLORS[index % CHART_MEASURE_COLORS.length], + })); + + return ( + + {dataKeys.map((key) => ( + + {!isMultiMeasure && ( + formatCellValue(value)} + /> + )} + + ))} + + ); +}; + +interface PieChartViewProps { + sortedData: TChartDataRow[]; + dataKeys: string[]; + dataKey: string; + isMultiMeasure: boolean; + query: TChartQuery; + timeDimKey: string | undefined; + xAxisKey: string; + chartConfig: ChartConfig; + formatDimensionValue: (value: unknown) => string; +} + +const PieChartView = ({ + sortedData, + dataKeys, + dataKey, + isMultiMeasure, + query, + timeDimKey, + xAxisKey, + chartConfig, + formatDimensionValue, +}: Readonly) => { + const { t } = useTranslation(); + + // With several measures and no dimension (e.g. the six Emotion counts), each row column is a + // measure, not a slice — pivot the measures into one slice per measure so the pie shows them + // all instead of only the first. A dimension-based pie keeps its existing (rows = slices) shape. + const useMeasureSlices = isMultiMeasure && (query.dimensions?.length ?? 0) === 0 && !timeDimKey; + const pieDataKey = useMeasureSlices ? PIE_MEASURE_VALUE_KEY : dataKey; + const pieNameKey = useMeasureSlices ? PIE_MEASURE_NAME_KEY : xAxisKey; + const pieSource = useMeasureSlices + ? prepareMeasureSliceData(sortedData, dataKeys, (key) => formatCubeColumnHeader(key, t)) + : sortedData; + const pieResult = preparePieData(pieSource, pieDataKey, pieNameKey); + if (!pieResult) { + return ( +
+ {t("workspace.analysis.charts.no_valid_data_to_display")} +
+ ); + } + const { processedData, colors } = pieResult; + const total = processedData.reduce((sum, row) => sum + (Number(row[pieDataKey]) || 0), 0); + // Multi-measure pies have no single measure to name the center; show just the total. + const centerLabel = useMeasureSlices ? "" : formatCubeColumnHeader(dataKey, t); + + return ( +
+ + + ReactElement`, + // but returning `null` from the function is supported at runtime and is + // how we hide the leader line for sub-2% slices. + labelLine={renderPieLabelLine as never} + label={renderPieLabel}> + {processedData.map((row, index) => { + const rowKey = row[pieNameKey] ?? `row-${index}`; + const uniqueKey = `${pieNameKey}-${String(rowKey)}-${index}`; + return ; + })} + + } /> + formatDimensionValue(value)} + wrapperStyle={{ fontSize: 12 }} + /> + + +
+ ); +}; + interface ChartRendererProps { chartType: TChartType; data: TChartDataRow[]; query: TChartQuery; + /** value_id → default-language label map, present when the query groups by valueId. */ + optionLabels?: Record; } -export function ChartRenderer({ chartType, data, query }: Readonly) { +export function ChartRenderer({ chartType, data, query, optionLabels }: Readonly) { const { t } = useTranslation(); // Unique across charts on the same page so SVG ids don't collide. const gradientIdPrefix = useId(); @@ -154,8 +349,13 @@ export function ChartRenderer({ chartType, data, query }: Readonly - getTranslatedDimensionValueLabel(xAxisKey, value, t) ?? formatXAxisTick(value); + const formatDimensionValue = (value: unknown): string => { + // If the x-axis is a valueId dimension, resolve via the option-label map first. + if (xAxisKey === "FeedbackRecords.valueId" && optionLabels && typeof value === "string") { + return optionLabels[value] ?? value; + } + return getTranslatedDimensionValueLabel(xAxisKey, value, t) ?? formatXAxisTick(value); + }; const measureIds = query.measures?.filter((m) => rowKeys.includes(m)) ?? []; const rawDataKeys = measureIds.length > 0 ? measureIds : rowKeys.filter((k) => k !== xAxisKey); @@ -191,88 +391,18 @@ export function ChartRenderer({ chartType, data, query }: Readonly 1; switch (chartType) { - case "bar": { - // Measure-only queries (no dimension or time grouping) return a single row with one - // column per measure. Rendered as N bar series that row forms a single category band - // that recharts centers in the plot, leaving a wide empty gap before the first bar - // (ENG-1796). Pivot the measures into categories so the bars fill the axis from the - // left like any other category bar chart, labelled by measure on the x-axis. - if (!hasCategoryAxis) { - // Sentiment measures pivot into the scale order the sentiment *dimension* axis uses, - // so this chart and a dimension-grouped one read in the same direction. - const axisKeys = sortMeasureIdsForCategoryAxis(dataKeys); - const measureData = pivotMeasuresToCategories(sortedData, axisKeys, (key) => - formatCubeColumnHeader(key, t) - ); - // Ticks use the short value label ("Very positive") — the full measure label is too - // wide, so recharts would thin the ticks and bars would lose their name. The tooltip - // keeps the full label via tooltipLabel. - const formatMeasureLabel = (value: unknown) => - getMeasureAxisLabel(typeof value === "string" ? value : "", t); - return ( - - - formatCellValue(value)} - /> - - - ); - } - - // Setting `fill` on the data row (not via ) is what propagates - // the per-bar colour into the tooltip payload as well as the SVG. - const barData = isMultiMeasure - ? sortedData - : sortedData.map((row, index) => ({ - ...row, - fill: - getSemanticDimensionColor(xAxisKey, row[xAxisKey]) ?? - CHART_MEASURE_COLORS[index % CHART_MEASURE_COLORS.length], - })); - + case "bar": return ( - - {dataKeys.map((key) => ( - - {!isMultiMeasure && ( - formatCellValue(value)} - /> - )} - - ))} - + xAxisKey={xAxisKey} + chartConfig={chartConfig} + formatDimensionValue={formatDimensionValue} + /> ); - } case "line": // AreaChart with a thin stroke + gradient fade reads as a line with a soft tint. return ( @@ -340,58 +470,20 @@ export function ChartRenderer({ chartType, data, query }: Readonly ); - case "pie": { - const pieResult = preparePieData(sortedData, dataKey, xAxisKey); - if (!pieResult) { - return ( -
- {t("workspace.analysis.charts.no_valid_data_to_display")} -
- ); - } - const { processedData, colors } = pieResult; - const total = processedData.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0); - const centerLabel = formatCubeColumnHeader(dataKey, t); - + case "pie": return ( -
- - - ReactElement`, - // but returning `null` from the function is supported at runtime and is - // how we hide the leader line for sub-2% slices. - labelLine={renderPieLabelLine as never} - label={renderPieLabel}> - {processedData.map((row, index) => { - const rowKey = row[xAxisKey] ?? `row-${index}`; - const uniqueKey = `${xAxisKey}-${String(rowKey)}-${index}`; - return ; - })} - - } /> - formatDimensionValue(value)} - wrapperStyle={{ fontSize: 12 }} - /> - - -
+ ); - } case "big_number": { const total = data.length === 1 diff --git a/apps/web/modules/ee/analysis/charts/components/data-viewer.tsx b/apps/web/modules/ee/analysis/charts/components/data-viewer.tsx index 4360122f4386..7002efcd545a 100644 --- a/apps/web/modules/ee/analysis/charts/components/data-viewer.tsx +++ b/apps/web/modules/ee/analysis/charts/components/data-viewer.tsx @@ -12,9 +12,11 @@ import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis"; const MAX_DISPLAY_ROWS = 50; interface DataViewerProps { data: TChartDataRow[]; + /** value_id → default-language label map, present when the query groups by valueId. */ + optionLabels?: Record; } -export function DataViewer({ data }: Readonly) { +export function DataViewer({ data, optionLabels }: Readonly) { const { t } = useTranslation(); if (!data || data.length === 0 || Object.keys(data[0]).length === 0) { return ( @@ -27,6 +29,13 @@ export function DataViewer({ data }: Readonly) { const columns = Object.keys(data[0]); const displayData = data.slice(0, MAX_DISPLAY_ROWS); + const renderCellValue = (key: string, value: unknown): string => { + if (key === "FeedbackRecords.valueId" && optionLabels && typeof value === "string") { + return optionLabels[value] ?? value; + } + return (getTranslatedDimensionValueLabel(key, value, t) ?? formatCellValue(value)) as string; + }; + return (
@@ -55,7 +64,7 @@ export function DataViewer({ data }: Readonly) {
{Object.entries(row).map(([key, value]) => ( ))} diff --git a/apps/web/modules/ee/analysis/charts/components/filter-field-combobox.tsx b/apps/web/modules/ee/analysis/charts/components/filter-field-combobox.tsx new file mode 100644 index 000000000000..460bc53d651c --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/components/filter-field-combobox.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { CheckIcon, ChevronDownIcon, SparklesIcon } from "lucide-react"; +import { useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "@/modules/ui/components/command"; +import { Input } from "@/modules/ui/components/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover"; +import { cn } from "@/modules/ui/lib/utils"; + +interface FilterFieldOption { + value: string; + label: string; + isGenerated?: boolean; +} + +interface FilterFieldComboboxProps { + options: FilterFieldOption[]; + value: string; + onChange: (value: string) => void; +} + +/** + * Searchable pick-list for the left (field) operand of a filter row. + * Mirrors the structure of FilterValueCombobox but filters purely client-side + * since the field list is static and small. + */ +export function FilterFieldCombobox({ options, value, onChange }: Readonly) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const listRef = useRef(null); + const listboxId = useId(); + + const trimmedSearch = search.trim().toLowerCase(); + const filtered = trimmedSearch + ? options.filter((opt) => opt.label.toLowerCase().includes(trimmedSearch)) + : options; + + const selectedLabel = options.find((opt) => opt.value === value)?.label; + + return ( + { + setOpen(nextOpen); + // Clear the search term when the popover closes. + if (!nextOpen) setSearch(""); + }}> + + + + event.preventDefault()} + onWheel={(event) => { + const list = listRef.current; + if (!list?.contains(event.target as Node)) return; + + const { scrollTop, scrollHeight, clientHeight } = list; + const canScrollUp = scrollTop > 0; + const canScrollDown = scrollTop + clientHeight < scrollHeight; + + if ((event.deltaY > 0 && canScrollDown) || (event.deltaY < 0 && canScrollUp)) { + list.scrollTop += event.deltaY; + event.preventDefault(); + } + }}> + +
+ setSearch(event.target.value)} + className="h-8 border-none bg-transparent p-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" + /> +
+
+ + {filtered.length === 0 && ( + {t("workspace.analysis.charts.no_fields_found")} + )} + {filtered.length > 0 && ( + + {filtered.map((option) => ( + { + onChange(option.value); + setOpen(false); + }}> + + + {option.isGenerated && ( + + + ))} + + )} + +
+
+
+
+ ); +} diff --git a/apps/web/modules/ee/analysis/charts/components/filters-panel.tsx b/apps/web/modules/ee/analysis/charts/components/filters-panel.tsx index ef069c1c9135..cd11d1e22ed9 100644 --- a/apps/web/modules/ee/analysis/charts/components/filters-panel.tsx +++ b/apps/web/modules/ee/analysis/charts/components/filters-panel.tsx @@ -1,14 +1,18 @@ "use client"; -import { Plus, SparklesIcon, TrashIcon } from "lucide-react"; +import { Plus, TrashIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { FilterDateInput } from "@/modules/ee/analysis/charts/components/filter-date-input"; +import { FilterFieldCombobox } from "@/modules/ee/analysis/charts/components/filter-field-combobox"; import { FilterValueCombobox } from "@/modules/ee/analysis/charts/components/filter-value-combobox"; import type { FilterRow, TFilterFieldType } from "@/modules/ee/analysis/lib/query-builder"; import { + EMOTIONS_DIMENSION_ID, + EMOTION_VALUES, FEEDBACK_FIELDS, getFieldById, getFilterOperatorsForType, + getTranslatedDimensionValueLabel, getTranslatedFieldLabel, isSelectableValueDimension, } from "@/modules/ee/analysis/lib/schema-definition"; @@ -52,12 +56,18 @@ export function FiltersPanel({ type: d.type, isGenerated: d.isGenerated ?? false, })), - ...FEEDBACK_FIELDS.measures.map((m) => ({ - value: m.id, - label: getTranslatedFieldLabel(m.id, t), - type: "number" as TFilterFieldType, - isGenerated: false, - })), + // Only continuous aggregate measures (scores + averages) make sense as filters — you + // threshold them (e.g. NPS score > 50, average sentiment > 0.5). Count measures are + // excluded: filtering by a count is either a no-op here or redundant with a dimension + // filter (e.g. "Sentiment: Positive" count vs. the Sentiment dimension = "positive"). + ...FEEDBACK_FIELDS.measures + .filter((m) => m.group === "score" || m.group === "average") + .map((m) => ({ + value: m.id, + label: getTranslatedFieldLabel(m.id, t), + type: "number" as TFilterFieldType, + isGenerated: false, + })), ]; const handleAddFilter = () => { @@ -107,6 +117,28 @@ export function FiltersPanel({ ); } + // Emotions is a multi-label comma-set, so its values can't come from a Cube distinct + // lookup (that returns joined combinations) and free text is error-prone. Offer the + // fixed emotion vocabulary as a pick-list; pair with `contains` to match one emotion. + if (filter.field === EMOTIONS_DIMENSION_ID) { + return ( + + ); + } + // Exact-match operators on a low-cardinality string dimension get a pick-list of // real stored values, so the chosen value matches exactly (no casing/whitespace drift). const canSelectValues = @@ -179,34 +211,25 @@ export function FiltersPanel({ return (
- + }} + /> - )} /> @@ -451,9 +209,8 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.submission_id")} - + - )} /> @@ -464,9 +221,8 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.collected_at")} - + - )} /> @@ -499,59 +255,18 @@ export const FeedbackRecordFormDrawer = ({ />
- {isEditMode ? ( - ( - - {t("workspace.unify.source_type")} - - - - - )} - /> - ) : ( -
- {t("workspace.unify.source_type")} - - {sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE && ( - { - setCustomSourceType(event.target.value); - form.setValue("source_type", event.target.value, { shouldDirty: true }); - }} - placeholder={t("workspace.unify.custom_source_type_placeholder")} - disabled={!canWrite} - /> - )} - {form.formState.errors.source_type?.message} -
- )} + ( + + {t("workspace.unify.source_type")} + + + + + )} + />
{t("workspace.unify.source_id")} - + )} @@ -573,7 +288,7 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.source_name")} - + )} @@ -588,9 +303,8 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.field_id")} - + - )} /> @@ -601,7 +315,7 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.field_label")} - + )} @@ -621,7 +335,7 @@ export const FeedbackRecordFormDrawer = ({ onValueChange={(value) => field.onChange(value as TFeedbackRecordFormValues["field_type"]) } - disabled={isEditMode || !canWrite}> + disabled> @@ -644,7 +358,7 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.field_group_id")} - + )} @@ -658,28 +372,36 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.field_group_label")} - + )} /> - ( - - {t("workspace.unify.value_text")} - - - - - )} - /> +
+ ( + + {t("workspace.unify.value_text")} + + + + + )} + /> + + {/* ENG-1673: read-only — the stable option identity is managed by ingestion/backfill; + hand-editing it would desync the record from its source option. Always shown so the + canonical Value ID is visible even when the record has none. */} +
+ + +
+
{translatedText && (
@@ -697,22 +419,7 @@ export const FeedbackRecordFormDrawer = ({
)} - {/* ENG-1673: read-only — the stable option identity is managed by ingestion/backfill; - hand-editing it would desync the record from its source option. */} - {record?.value_id && ( -
-
-
-

{record.value_id}

-

{t("workspace.unify.value_id_hint")}

-
- )} - - {record && selectedValueField === "value_text" && ( + {selectedValueField === "value_text" && (
@@ -803,7 +499,6 @@ export const FeedbackRecordFormDrawer = ({ )} /> - {form.formState.errors[selectedValueField]?.message}
{t("common.language")} - + )} @@ -825,71 +520,16 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.user_identifier")} - + )} />
-
-
- {t("workspace.unify.metadata")} - {canWrite && !isReadOnly && ( - - )} -
- + {readOnlyMetadataEntries.length > 0 && (
- {fields.map((field, index) => ( -
- ( - - - - - - )} - /> - ( - - - - - - )} - /> - {canWrite && !isReadOnly && ( - - )} -
- ))} -
- - {readOnlyMetadataEntries.length > 0 && ( + {t("workspace.unify.metadata")}

{t("workspace.unify.metadata_read_only_entries")} @@ -905,52 +545,30 @@ export const FeedbackRecordFormDrawer = ({

))}
- )} -
- +
+ )} + )} - {isEditMode && canWrite && recordId ? ( + {canWrite && recordId ? ( ) : ( )} -
- - {canWrite && ( - - )} -
+
- setIsDiscardDialogOpen(false)} - onConfirm={handleDiscardChanges} - /> - (null); - const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit"); const [drawerRecordId, setDrawerRecordId] = useState(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [csvImportSource, setCsvImportSource] = useState<{ @@ -338,18 +336,11 @@ export const FeedbackRecordsTable = ({ } }; - const openEditDrawer = (recordId: string) => { - setDrawerMode("edit"); + const openViewDrawer = (recordId: string) => { setDrawerRecordId(recordId); setIsDrawerOpen(true); }; - const openCreateDrawer = () => { - setDrawerMode("create"); - setDrawerRecordId(undefined); - setIsDrawerOpen(true); - }; - const hasCsvSources = csvSources.length > 0; let headerCheckboxChecked: boolean | "indeterminate" = false; @@ -371,38 +362,28 @@ export const FeedbackRecordsTable = ({ onBulkDelete={() => setIsBulkDeleteDialogOpen(true)} />
- {canWrite && - (hasCsvSources ? ( - - - - - - - {t("workspace.unify.add_feedback_record")} + {canWrite && hasCsvSources && ( + + + + + + {csvSources.map((source) => ( + { + setCsvImportSource(source); + }}> + {t("workspace.unify.import_via_source_name", { sourceName: source.name })} - - {csvSources.map((source) => ( - { - setCsvImportSource(source); - }}> - {t("workspace.unify.import_via_source_name", { sourceName: source.name })} - - ))} - - - ) : ( - - ))} + ))} + + + )}
@@ -499,13 +480,12 @@ export const FeedbackRecordsTable = ({ diff --git a/apps/web/modules/ee/unify-feedback/lib/utils.test.ts b/apps/web/modules/ee/unify-feedback/lib/utils.test.ts index 6e4f44528054..5b0c4300f91a 100644 --- a/apps/web/modules/ee/unify-feedback/lib/utils.test.ts +++ b/apps/web/modules/ee/unify-feedback/lib/utils.test.ts @@ -1,9 +1,8 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import type { FeedbackRecordData } from "@/modules/hub/types"; import { formatFieldType, formatSourceType, - getCreateDefaults, getReadOnlyMetadataEntries, getValueFieldByType, isPresetSourceType, @@ -14,8 +13,6 @@ import { toLocalDateTimeInput, } from "./utils"; -vi.mock("uuid", () => ({ v7: () => "mock-uuid-v7" })); - const makeRecord = (overrides: Partial = {}): FeedbackRecordData => ({ id: "rec-1", tenant_id: "tenant-1", @@ -74,22 +71,6 @@ describe("toISOOrUndefined", () => { }); }); -describe("getCreateDefaults", () => { - test("uses first directory as tenant_id", () => { - const dirs = [{ id: "dir-1", name: "Dir 1" }]; - const result = getCreateDefaults(dirs); - expect(result.tenant_id).toBe("dir-1"); - expect(result.submission_id).toBe("mock-uuid-v7"); - expect(result.field_type).toBe("text"); - expect(result.metadataEntries).toEqual([]); - }); - - test("handles empty directories", () => { - const result = getCreateDefaults([]); - expect(result.tenant_id).toBe(""); - }); -}); - describe("mapRecordToValues", () => { test("maps a full record", () => { const record = makeRecord({ diff --git a/apps/web/modules/ee/unify-feedback/lib/utils.ts b/apps/web/modules/ee/unify-feedback/lib/utils.ts index c5fff5a9349d..68561f9b6b06 100644 --- a/apps/web/modules/ee/unify-feedback/lib/utils.ts +++ b/apps/web/modules/ee/unify-feedback/lib/utils.ts @@ -1,5 +1,4 @@ import { TFunction } from "i18next"; -import { v7 as uuidv7 } from "uuid"; import type { FeedbackRecordData } from "@/modules/hub/types"; import { SOURCE_TYPE_PRESET_OPTIONS, type TFeedbackRecordFormValues } from "./types"; @@ -74,35 +73,6 @@ export const toISOOrUndefined = (dateTimeValue: string | undefined): string | un return parsed.toISOString(); }; -export const getCreateDefaults = (directories: { id: string; name: string }[]): TFeedbackRecordFormValues => { - const now = new Date(); - const defaultDirectoryId = directories[0]?.id ?? ""; - - return { - id: "", - tenant_id: defaultDirectoryId, - submission_id: uuidv7(), - collected_at: toLocalDateTimeInput(now.toISOString()), - created_at: "", - updated_at: "", - source_type: "survey", - source_id: "", - source_name: "", - field_id: "", - field_label: "", - field_type: "text", - field_group_id: "", - field_group_label: "", - value_text: "", - value_number: "", - value_boolean: undefined, - value_date: "", - language: "", - user_id: "", - metadataEntries: [], - }; -}; - export const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFormValues => { const metadataEntries = Object.entries(record.metadata ?? {}) .filter(([, value]) => typeof value === "string") diff --git a/apps/web/modules/ee/unify-feedback/types.ts b/apps/web/modules/ee/unify-feedback/types.ts index 68a1fb3d9dbe..e8824c876325 100644 --- a/apps/web/modules/ee/unify-feedback/types.ts +++ b/apps/web/modules/ee/unify-feedback/types.ts @@ -3,60 +3,6 @@ import { ZId } from "@formbricks/types/common"; export const ZFeedbackRecordId = z.uuid(); -export const ZFeedbackRecordFieldType = z.enum([ - "text", - "categorical", - "nps", - "csat", - "ces", - "rating", - "number", - "boolean", - "date", -]); - -export const ZFeedbackRecordMetadata = z.record(z.string(), z.unknown()); - -export const ZFeedbackRecordCreateInput = z.object({ - submission_id: z.string().min(1), - tenant_id: ZId, - source_type: z.string().min(1), - field_id: z.string().min(1), - field_type: ZFeedbackRecordFieldType, - collected_at: z.iso.datetime().optional(), - source_id: z.string().optional().nullable(), - source_name: z.string().optional().nullable(), - field_label: z.string().optional().nullable(), - field_group_id: z.string().optional(), - field_group_label: z.string().optional().nullable(), - value_text: z.string().optional().nullable(), - value_number: z.number().optional(), - value_boolean: z.boolean().optional(), - value_date: z.iso.datetime().optional(), - metadata: ZFeedbackRecordMetadata.optional(), - language: z.string().optional(), - user_id: z.string().optional(), -}); - -export type TFeedbackRecordCreateInput = z.infer; - -export const ZFeedbackRecordUpdateInput = z - .object({ - value_text: z.string().optional().nullable(), - value_number: z.number().optional().nullable(), - value_boolean: z.boolean().optional().nullable(), - value_date: z.iso.datetime().optional().nullable(), - language: z.string().optional().nullable(), - metadata: ZFeedbackRecordMetadata.optional(), - user_id: z.string().optional().nullable(), - }) - .refine( - (value) => Object.values(value).some((entry) => entry !== undefined), - "At least one field must be provided for update" - ); - -export type TFeedbackRecordUpdateInput = z.infer; - export const ZRetrieveFeedbackRecordAction = z.object({ workspaceId: ZId, recordId: ZFeedbackRecordId, @@ -64,21 +10,6 @@ export const ZRetrieveFeedbackRecordAction = z.object({ export type TRetrieveFeedbackRecordAction = z.infer; -export const ZCreateFeedbackRecordAction = z.object({ - workspaceId: ZId, - recordInput: ZFeedbackRecordCreateInput, -}); - -export type TCreateFeedbackRecordAction = z.infer; - -export const ZUpdateFeedbackRecordAction = z.object({ - workspaceId: ZId, - recordId: ZFeedbackRecordId, - updateInput: ZFeedbackRecordUpdateInput, -}); - -export type TUpdateFeedbackRecordAction = z.infer; - export const ZDeleteFeedbackRecordAction = z.object({ workspaceId: ZId, recordId: ZFeedbackRecordId, diff --git a/apps/web/modules/ui/components/multi-select/index.tsx b/apps/web/modules/ui/components/multi-select/index.tsx index b3e4d644df84..4ab93c561c5f 100644 --- a/apps/web/modules/ui/components/multi-select/index.tsx +++ b/apps/web/modules/ui/components/multi-select/index.tsx @@ -264,7 +264,7 @@ export function MultiSelect["value"][]>( left: `${position.left}px`, width: `${position.width}px`, }}> - +
{optionGroups.map((group) => ( Date: Mon, 20 Jul 2026 07:24:19 +0000 Subject: [PATCH 3/7] feat(mcp): add a list_workspaces tool (ENG-1776) (#8577) --- apps/web/app/api/mcp/route.test.ts | 1 + .../api/v3/workspaces/lib/operations.test.ts | 122 ++++++++++++++++++ .../app/api/v3/workspaces/lib/operations.ts | 109 ++++++++++++++++ apps/web/lib/workspace/service.test.ts | 63 ++++++++- apps/web/lib/workspace/service.ts | 10 +- apps/web/modules/mcp/server.ts | 2 + apps/web/modules/mcp/tools/guard-scopes.ts | 23 ++++ apps/web/modules/mcp/tools/schemas.ts | 4 + apps/web/modules/mcp/tools/surveys.ts | 25 +--- apps/web/modules/mcp/tools/workspaces.test.ts | 87 +++++++++++++ apps/web/modules/mcp/tools/workspaces.ts | 41 ++++++ 11 files changed, 461 insertions(+), 26 deletions(-) create mode 100644 apps/web/app/api/v3/workspaces/lib/operations.test.ts create mode 100644 apps/web/app/api/v3/workspaces/lib/operations.ts create mode 100644 apps/web/modules/mcp/tools/guard-scopes.ts create mode 100644 apps/web/modules/mcp/tools/workspaces.test.ts create mode 100644 apps/web/modules/mcp/tools/workspaces.ts diff --git a/apps/web/app/api/mcp/route.test.ts b/apps/web/app/api/mcp/route.test.ts index 7cc1c7de3df7..744bd94c2006 100644 --- a/apps/web/app/api/mcp/route.test.ts +++ b/apps/web/app/api/mcp/route.test.ts @@ -212,6 +212,7 @@ describe("POST /api/mcp", () => { "validate_survey", "patch_survey", "delete_survey", + "list_workspaces", ]); const tools = new Map(message.result.tools.map((tool: { name: string }) => [tool.name, tool])); expect(Object.keys((tools.get("create_survey") as any).inputSchema.properties)).toEqual( diff --git a/apps/web/app/api/v3/workspaces/lib/operations.test.ts b/apps/web/app/api/v3/workspaces/lib/operations.test.ts new file mode 100644 index 000000000000..c5e67b682c98 --- /dev/null +++ b/apps/web/app/api/v3/workspaces/lib/operations.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { TV3Authentication } from "@/app/api/v3/lib/types"; +import { getOrganizationsByUserId } from "@/lib/organization/service"; +import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service"; +import { listV3Workspaces } from "./operations"; + +vi.mock("@/lib/organization/service", () => ({ getOrganizationsByUserId: vi.fn() })); +vi.mock("@/lib/workspace/service", () => ({ getUserWorkspaces: vi.fn(), getWorkspace: vi.fn() })); +vi.mock("@formbricks/logger", () => ({ + logger: { withContext: vi.fn(() => ({ error: vi.fn(), warn: vi.fn() })) }, +})); + +const sessionAuth = { + user: { id: "user_1" }, + expires: "2026-07-01T00:00:00.000Z", +} as unknown as TV3Authentication; + +const params = (authentication: TV3Authentication) => ({ + authentication, + requestId: "req_1", + instance: "/api/mcp", +}); + +// A workspace as the services return it: full entity. The operation must serialize it down to the DTO. +const ws = (id: string, name: string, organizationId: string) => + ({ + id, + name, + organizationId, + createdAt: new Date(), + updatedAt: new Date(), + config: { secret: "should-not-leak" }, + styling: {}, + }) as any; + +describe("listV3Workspaces", () => { + beforeEach(() => vi.clearAllMocks()); + + test("session user: aggregates + dedupes across orgs and returns the minimal DTO only", async () => { + vi.mocked(getOrganizationsByUserId).mockResolvedValue([ + { id: "org_1", name: "Org 1" }, + { id: "org_2", name: "Org 2" }, + ] as any); + vi.mocked(getUserWorkspaces).mockImplementation(async (_userId: string, orgId: string) => + orgId === "org_1" + ? [ws("w1", "Alpha", "org_1"), ws("w2", "Beta", "org_1")] + : [ws("w2", "Beta", "org_1"), ws("w3", "Gamma", "org_2")] + ); + + const res = await listV3Workspaces(params(sessionAuth)); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.data).toEqual([ + { id: "w1", name: "Alpha", organizationId: "org_1" }, + { id: "w2", name: "Beta", organizationId: "org_1" }, + { id: "w3", name: "Gamma", organizationId: "org_2" }, + ]); + expect(body.meta.totalCount).toBe(3); + // Only the DTO fields — no config/styling/entity internals leak. + expect(Object.keys(body.data[0])).toEqual(["id", "name", "organizationId"]); + }); + + test("returns workspaces in a deterministic order (name, then id)", async () => { + vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org_1", name: "Org 1" }] as any); + vi.mocked(getUserWorkspaces).mockResolvedValue([ + ws("w3", "Zeta", "org_1"), + ws("w1", "alpha", "org_1"), + ws("w2", "Beta", "org_1"), + ]); + + const res = await listV3Workspaces(params(sessionAuth)); + const body = await res.json(); + + expect(body.data.map((w: { name: string }) => w.name)).toEqual(["alpha", "Beta", "Zeta"]); + }); + + test("session user with no orgs → empty list, no workspace lookups", async () => { + vi.mocked(getOrganizationsByUserId).mockResolvedValue([] as any); + + const res = await listV3Workspaces(params(sessionAuth)); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data).toEqual([]); + expect(getUserWorkspaces).not.toHaveBeenCalled(); + }); + + test("api key: returns only the workspaces in workspacePermissions", async () => { + const keyAuth = { + apiKeyId: "key_1", + workspacePermissions: [ + { workspaceId: "w1", permission: "read" }, + { workspaceId: "w9", permission: "write" }, + ], + } as unknown as TV3Authentication; + vi.mocked(getWorkspace).mockImplementation(async (id: string) => + id === "w1" ? ws("w1", "Alpha", "org_1") : id === "w9" ? ws("w9", "Zeta", "org_2") : null + ); + + const res = await listV3Workspaces(params(keyAuth)); + const body = await res.json(); + + expect(body.data).toEqual([ + { id: "w1", name: "Alpha", organizationId: "org_1" }, + { id: "w9", name: "Zeta", organizationId: "org_2" }, + ]); + // API-key path must never fall back to the user/org aggregation. + expect(getOrganizationsByUserId).not.toHaveBeenCalled(); + }); + + test("no authentication → 401", async () => { + const res = await listV3Workspaces(params(null)); + expect(res.status).toBe(401); + }); + + test("an unexpected service failure is logged and returned as a 500 (never thrown)", async () => { + vi.mocked(getOrganizationsByUserId).mockRejectedValue(new Error("db exploded")); + const res = await listV3Workspaces(params(sessionAuth)); + expect(res.status).toBe(500); + }); +}); diff --git a/apps/web/app/api/v3/workspaces/lib/operations.ts b/apps/web/app/api/v3/workspaces/lib/operations.ts new file mode 100644 index 000000000000..1cb80d6e02a7 --- /dev/null +++ b/apps/web/app/api/v3/workspaces/lib/operations.ts @@ -0,0 +1,109 @@ +import "server-only"; +import { logger } from "@formbricks/logger"; +import type { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { problemInternalError, problemUnauthorized, successListResponse } from "@/app/api/v3/lib/response"; +import type { TV3Authentication } from "@/app/api/v3/lib/types"; +import { getOrganizationsByUserId } from "@/lib/organization/service"; +import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service"; + +type TListV3WorkspacesParams = { + authentication: TV3Authentication; + requestId: string; + instance: string; +}; + +/** Minimal DTO — never return the raw Workspace entity (no config/env/styling internals). */ +type TV3WorkspaceListItem = { + id: string; + name: string; + organizationId: string; +}; + +const serializeV3WorkspaceListItem = (workspace: { + id: string; + name: string; + organizationId: string; +}): TV3WorkspaceListItem => ({ + id: workspace.id, + name: workspace.name, + organizationId: workspace.organizationId, +}); + +/** + * Session user's accessible workspaces: every workspace across the orgs they're a member of. Scoping is + * enforced by the reused services — `getOrganizationsByUserId` limits to the user's own orgs, and + * `getUserWorkspaces` returns all of an org's workspaces for owners/managers but only team-scoped ones + * for `member`-role users. + */ +async function fetchSessionWorkspaces(userId: string): Promise { + const organizations = await getOrganizationsByUserId(userId); + const workspacesPerOrg = await Promise.all( + organizations.map((organization) => getUserWorkspaces(userId, organization.id)) + ); + return workspacesPerOrg.flat().map(serializeV3WorkspaceListItem); +} + +/** API key's accessible workspaces: exactly the ones named in its `workspacePermissions`, nothing else. */ +async function fetchApiKeyWorkspaces(keyAuth: TAuthenticationApiKey): Promise { + const workspaceIds = Array.from(new Set(keyAuth.workspacePermissions.map((p) => p.workspaceId))); + const workspaces = await Promise.all(workspaceIds.map((id) => getWorkspace(id))); + return workspaces + .filter((workspace): workspace is NonNullable => workspace !== null) + .map(serializeV3WorkspaceListItem); +} + +/** + * List the workspaces the authenticated principal can access (session user or API key). There is no + * `workspaceId` input, so there is no IDOR surface — the result is always derived from the caller's own + * live membership / key grants, never from client-supplied ids. + * + * Thin orchestrator: resolve the principal's workspaces → dedupe → order → cap → respond. The per- + * principal access resolution lives in the `fetch*Workspaces` helpers. + */ +export async function listV3Workspaces({ + authentication, + requestId, + instance, +}: TListV3WorkspacesParams): Promise { + if (!authentication) { + return problemUnauthorized(requestId, "Not authenticated", instance); + } + + const log = logger.withContext({ requestId }); + + try { + let items: TV3WorkspaceListItem[]; + + if ("user" in authentication && authentication.user?.id) { + items = await fetchSessionWorkspaces(authentication.user.id); + } else if ( + "apiKeyId" in authentication && + authentication.apiKeyId && + Array.isArray(authentication.workspacePermissions) + ) { + items = await fetchApiKeyWorkspaces(authentication); + } else { + return problemUnauthorized(requestId, "Not authenticated", instance); + } + + // Dedupe by id (defensive) + a stable, deterministic order — the underlying queries have no ORDER BY, + // so without this the output would vary between calls. + const deduped = Array.from(new Map(items.map((item) => [item.id, item])).values()).sort( + (a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id) + ); + + // No pagination: a principal's accessible workspaces are bounded by their memberships (small), so we + // return them all. No arbitrary cap — a truncation here would silently hide workspaces (the tool + // accepts no cursor) without bounding DB work, which was already done above. + return successListResponse( + deduped, + { nextCursor: null, totalCount: deduped.length }, + { requestId, cache: "private, no-store" } + ); + } catch (error) { + // Log every failure with request context (not just DatabaseError) so nothing significant is lost, + // and always return a clean 500 instead of throwing a raw error past this boundary. + log.error({ error, statusCode: 500 }, "Failed to list workspaces"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } +} diff --git a/apps/web/lib/workspace/service.test.ts b/apps/web/lib/workspace/service.test.ts index 5fc68fe487a7..7d717d5e60ff 100644 --- a/apps/web/lib/workspace/service.test.ts +++ b/apps/web/lib/workspace/service.test.ts @@ -4,7 +4,12 @@ import { prisma } from "@formbricks/database"; import { OrganizationRole, Prisma, WidgetPlacement, Workspace } from "@formbricks/database/prisma"; import { DatabaseError, ValidationError } from "@formbricks/types/errors"; import { ITEMS_PER_PAGE } from "../constants"; -import { getUserWorkspaces, getWorkspace, getWorkspaces } from "./service"; +import { + getUserWorkspaces, + getUserWorkspacesByOrganizationIds, + getWorkspace, + getWorkspaces, +} from "./service"; vi.mock("@formbricks/database", () => ({ prisma: { @@ -221,6 +226,62 @@ describe("Workspace Service", () => { }); }); + test("getUserWorkspacesByOrganizationIds team-scopes non-owner/manager roles (owner/manager get all)", async () => { + const userId = createId(); + const orgManager = createId(); + const orgBilling = createId(); + + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { userId, organizationId: orgManager, role: OrganizationRole.manager, accepted: true }, + { userId, organizationId: orgBilling, role: OrganizationRole.billing, accepted: true }, + ]); + vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); + + await getUserWorkspacesByOrganizationIds([orgManager, orgBilling], userId); + + const teamScope = { some: { team: { teamUsers: { some: { userId } } } } }; + expect(prisma.workspace.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + // manager: all of the org's workspaces (no team filter) + { organizationId: orgManager }, + // billing: team-scoped only (regression: previously unscoped → every workspace) + { organizationId: orgBilling, workspaceTeams: teamScope }, + ], + }, + select: { id: true }, + }); + }); + + test("getUserWorkspaces should team-scope a billing user (not return every workspace)", async () => { + const userId = createId(); + const organizationId = createId(); + + vi.mocked(prisma.membership.findFirst).mockResolvedValue({ + userId, + organizationId, + role: OrganizationRole.billing, + accepted: true, + }); + vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); + + await getUserWorkspaces(userId, organizationId); + + // Billing is not owner/manager, so it must be scoped to team-accessible workspaces — never the + // whole org (regression: `role === "member"` previously leaked all workspaces to billing users). + expect(prisma.workspace.findMany).toHaveBeenCalledWith({ + where: { + organizationId, + workspaceTeams: { + some: { team: { teamUsers: { some: { userId } } } }, + }, + }, + select: expect.any(Object), + take: undefined, + skip: undefined, + }); + }); + test("getUserWorkspaces should throw ValidationError when user is not a member of organization", async () => { const userId = createId(); const organizationId = createId(); diff --git a/apps/web/lib/workspace/service.ts b/apps/web/lib/workspace/service.ts index c7cf431829bb..781923031dd6 100644 --- a/apps/web/lib/workspace/service.ts +++ b/apps/web/lib/workspace/service.ts @@ -46,7 +46,11 @@ export const getUserWorkspaces = reactCache( let workspaceWhereClause: Prisma.WorkspaceWhereInput = {}; - if (orgMembership.role === "member") { + // Only org owners/managers get every workspace. Every other role (member, billing, …) is scoped to + // the workspaces whose teams they belong to — mirroring the per-workspace v3 authorization + // (`requireV3WorkspaceAccess`: org owner/manager OR workspace-team membership). Special-casing only + // `member` here previously leaked all workspaces to `billing` members, who cannot access them. + if (orgMembership.role !== "owner" && orgMembership.role !== "manager") { workspaceWhereClause = { workspaceTeams: { some: { @@ -171,7 +175,9 @@ export const getUserWorkspacesByOrganizationIds = reactCache( organizationId: membership.organizationId, }; - if (membership.role === "member") { + // Same scoping as getUserWorkspaces: only owner/manager see all of an org's workspaces; every + // other role (member, billing, …) is limited to their team-scoped workspaces. + if (membership.role !== "owner" && membership.role !== "manager") { workspaceWhereClause = { ...workspaceWhereClause, workspaceTeams: { diff --git a/apps/web/modules/mcp/server.ts b/apps/web/modules/mcp/server.ts index c30b1bc8858b..a3a8e0ee987a 100644 --- a/apps/web/modules/mcp/server.ts +++ b/apps/web/modules/mcp/server.ts @@ -2,10 +2,12 @@ import "server-only"; import { createMcpHandler } from "mcp-handler"; import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "./constants"; import { registerSurveyTools } from "./tools/surveys"; +import { registerWorkspaceTools } from "./tools/workspaces"; export const mcpHandler = createMcpHandler( (server) => { registerSurveyTools(server); + registerWorkspaceTools(server); }, { serverInfo: { diff --git a/apps/web/modules/mcp/tools/guard-scopes.ts b/apps/web/modules/mcp/tools/guard-scopes.ts new file mode 100644 index 000000000000..90a78ed0a044 --- /dev/null +++ b/apps/web/modules/mcp/tools/guard-scopes.ts @@ -0,0 +1,23 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { createMcpInsufficientScopeResponse, hasMcpScopes } from "../auth"; +import { responseToMcpToolResult } from "../errors"; + +/** + * Shared MCP scope gate: returns `null` when the caller holds all `requiredScopes`, otherwise an + * insufficient-scope tool result. Used by every tool before it touches a v3 operation. + */ +export async function guardMcpScopes( + authInfo: AuthInfo | undefined, + requiredScopes: string[], + requestId: string +): Promise { + if (hasMcpScopes(authInfo, requiredScopes)) { + return null; + } + + return await responseToMcpToolResult( + createMcpInsufficientScopeResponse(requestId, requiredScopes), + requestId + ); +} diff --git a/apps/web/modules/mcp/tools/schemas.ts b/apps/web/modules/mcp/tools/schemas.ts index d13cd76c37c7..c92c28238d30 100644 --- a/apps/web/modules/mcp/tools/schemas.ts +++ b/apps/web/modules/mcp/tools/schemas.ts @@ -119,7 +119,11 @@ export const ZMcpDeleteSurveyInput = z.object({ surveyId: z.cuid2().describe("Survey ID to delete."), }); +// list_workspaces takes no arguments — it returns the workspaces the authenticated caller can access. +export const ZMcpListWorkspacesInput = z.object({}); + export type TMcpListSurveysInput = z.infer; +export type TMcpListWorkspacesInput = z.infer; export type TMcpGetSurveyInput = z.infer; export type TMcpCreateSurveyInput = z.infer; export type TMcpPatchSurveyInput = z.infer; diff --git a/apps/web/modules/mcp/tools/surveys.ts b/apps/web/modules/mcp/tools/surveys.ts index 8c2d24f1d91e..dd9197d6ba11 100644 --- a/apps/web/modules/mcp/tools/surveys.ts +++ b/apps/web/modules/mcp/tools/surveys.ts @@ -1,6 +1,4 @@ -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { logger } from "@formbricks/logger"; import { buildV3AuditLog, queueV3AuditLog } from "@/app/api/v3/lib/audit"; import { @@ -12,13 +10,9 @@ import { validateV3SurveyFromRawInput, } from "@/app/api/v3/surveys/lib/operations"; import { MCP_API_ROUTE } from "@/modules/mcp/constants"; -import { - createMcpInsufficientScopeResponse, - getMcpAuthentication, - getMcpRequestId, - hasMcpScopes, -} from "../auth"; +import { getMcpAuthentication, getMcpRequestId } from "../auth"; import { responseToMcpToolResult } from "../errors"; +import { guardMcpScopes } from "./guard-scopes"; import { type TMcpCreateSurveyInput, type TMcpDeleteSurveyInput, @@ -67,21 +61,6 @@ export function buildListSurveysSearchParams(input: TMcpListSurveysInput): URLSe return searchParams; } -async function guardMcpScopes( - authInfo: AuthInfo | undefined, - requiredScopes: string[], - requestId: string -): Promise { - if (hasMcpScopes(authInfo, requiredScopes)) { - return null; - } - - return await responseToMcpToolResult( - createMcpInsufficientScopeResponse(requestId, requiredScopes), - requestId - ); -} - export function registerSurveyTools(server: McpServer): void { server.registerTool( "list_surveys", diff --git a/apps/web/modules/mcp/tools/workspaces.test.ts b/apps/web/modules/mcp/tools/workspaces.test.ts new file mode 100644 index 000000000000..4a8e1b37d33c --- /dev/null +++ b/apps/web/modules/mcp/tools/workspaces.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { successListResponse } from "@/app/api/v3/lib/response"; +import { listV3Workspaces } from "@/app/api/v3/workspaces/lib/operations"; +import { registerWorkspaceTools } from "./workspaces"; + +vi.mock("@/app/api/v3/workspaces/lib/operations", () => ({ + listV3Workspaces: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { withContext: vi.fn(() => ({ error: vi.fn(), warn: vi.fn() })) }, +})); + +const oauthSession = { + user: { id: "user_1", email: "person@example.com", name: "Person" }, + expires: "2026-07-01T00:00:00.000Z", +}; + +const readAuthInfo = { + token: "oauth:user_1:client_1", + clientId: "client_1", + scopes: ["surveys:read"], + extra: { formbricksAuthentication: oauthSession, requestId: "req_tool", authMethod: "oauth" }, +}; + +const writeOnlyAuthInfo = { ...readAuthInfo, scopes: ["surveys:write"] }; + +function createToolServer() { + const tools = new Map< + string, + { config: Record; handler: (input: any, extra: any) => Promise } + >(); + const server = { + registerTool: vi.fn((name: string, config: Record, handler: any) => { + tools.set(name, { config, handler }); + }), + }; + registerWorkspaceTools(server as any); + return { server, tools }; +} + +describe("registerWorkspaceTools", () => { + beforeEach(() => vi.clearAllMocks()); + + test("registers list_workspaces as a read-only tool", () => { + const { tools } = createToolServer(); + const tool = tools.get("list_workspaces"); + expect(tool).toBeDefined(); + expect(tool!.config.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }); + }); + + test("calls the v3 list-workspaces operation and returns structured content", async () => { + const { tools } = createToolServer(); + vi.mocked(listV3Workspaces).mockResolvedValue( + successListResponse( + [{ id: "w1", name: "Alpha", organizationId: "org_1" }], + { nextCursor: null, totalCount: 1 }, + { requestId: "req_tool" } + ) + ); + + const result = await tools.get("list_workspaces")!.handler({}, { authInfo: readAuthInfo }); + + expect(listV3Workspaces).toHaveBeenCalledWith( + expect.objectContaining({ + authentication: oauthSession, + requestId: "req_tool", + instance: "/api/mcp", + }) + ); + expect(result.structuredContent).toEqual({ + data: [{ id: "w1", name: "Alpha", organizationId: "org_1" }], + meta: { nextCursor: null, totalCount: 1 }, + requestId: "req_tool", + }); + }); + + test("returns an insufficient-scope error without surveys:read (and skips the operation)", async () => { + const { tools } = createToolServer(); + + const result = await tools.get("list_workspaces")!.handler({}, { authInfo: writeOnlyAuthInfo }); + + expect(listV3Workspaces).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + expect(result.structuredContent.error).toMatchObject({ status: 403 }); + }); +}); diff --git a/apps/web/modules/mcp/tools/workspaces.ts b/apps/web/modules/mcp/tools/workspaces.ts new file mode 100644 index 000000000000..ed2c08316794 --- /dev/null +++ b/apps/web/modules/mcp/tools/workspaces.ts @@ -0,0 +1,41 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { listV3Workspaces } from "@/app/api/v3/workspaces/lib/operations"; +import { MCP_API_ROUTE } from "@/modules/mcp/constants"; +import { getMcpAuthentication, getMcpRequestId } from "../auth"; +import { responseToMcpToolResult } from "../errors"; +import { guardMcpScopes } from "./guard-scopes"; +import { type TMcpListWorkspacesInput, ZMcpListWorkspacesInput } from "./schemas"; + +export function registerWorkspaceTools(server: McpServer): void { + server.registerTool( + "list_workspaces", + { + title: "List workspaces", + description: + "List the Formbricks workspaces the authenticated user can access. Use this to discover the workspaceId required by the survey tools.", + inputSchema: ZMcpListWorkspacesInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async (_input: TMcpListWorkspacesInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + // Listing workspaces is the read-prerequisite for the survey read tools, so it reuses surveys:read. + const scopeError = await guardMcpScopes(extra.authInfo, ["surveys:read"], requestId); + if (scopeError) { + return scopeError; + } + + const response = await listV3Workspaces({ + authentication: getMcpAuthentication(extra.authInfo), + requestId, + instance: MCP_API_ROUTE, + }); + + return await responseToMcpToolResult(response, requestId); + } + ); +} From adb244f76983389dbbf92062cb4bb076bad9c4ba Mon Sep 17 00:00:00 2001 From: Matti Nannt <675065+mattinannt@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:50:52 +0200 Subject: [PATCH 4/7] chore(pnpm): remove legacyPeerDeps no-op, document node-linker decision (#8499) Co-authored-by: Claude Fable 5 --- pnpm-workspace.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aa9e4a8f27ce..d11a84e72c3c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -83,7 +83,12 @@ shamefullyHoist: true sharedWorkspaceLockfile: true access: public enablePrePostScripts: true -legacyPeerDeps: true +# Flat, npm-style node_modules. Deliberate for now: several packages still rely on hoisted +# (undeclared) dependencies — e.g. @formbricks/js-core reaches into sibling packages (ENG-1681) +# and react/react-dom are hoisted from the workspace root (ENG-1693). Moving to the strict +# `isolated` linker is the long-term goal (ENG-1674); fix the undeclared deps first. +# `shamefullyHoist` above is left untouched here — it becomes meaningful only under the isolated +# linker, so it is handled together with that migration rather than in this cleanup. nodeLinker: hoisted saveExact: true # Supply-chain release cooldown: refuse to install package versions published less than From c56d2dce98df8b740107ccc4f6acb16b89b4e1f4 Mon Sep 17 00:00:00 2001 From: Tiago <1585571+xernobyl@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:12:52 +0000 Subject: [PATCH 5/7] fix(auth): read the correct field from the OAuth consent response (MCP) (#8575) --- .../components/OAuthConsentActions.tsx | 16 ++--- .../authorize/lib/consent-redirect.test.ts | 68 +++++++++++++++++++ .../account/authorize/lib/consent-redirect.ts | 37 ++++++++++ 3 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 apps/web/app/(app)/account/authorize/lib/consent-redirect.test.ts create mode 100644 apps/web/app/(app)/account/authorize/lib/consent-redirect.ts diff --git a/apps/web/app/(app)/account/authorize/components/OAuthConsentActions.tsx b/apps/web/app/(app)/account/authorize/components/OAuthConsentActions.tsx index 629f022dbfd0..2bb0f517677f 100644 --- a/apps/web/app/(app)/account/authorize/components/OAuthConsentActions.tsx +++ b/apps/web/app/(app)/account/authorize/components/OAuthConsentActions.tsx @@ -5,6 +5,7 @@ import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { authClient } from "@/modules/auth/lib/auth-client"; import { Button } from "@/modules/ui/components/button"; +import { resolveConsentRedirectUrl } from "../lib/consent-redirect"; export const OAuthConsentActions = () => { const { t } = useTranslation(); @@ -21,21 +22,14 @@ export const OAuthConsentActions = () => { return; } - const redirectUri = - response.data && - typeof response.data === "object" && - "redirect_uri" in response.data && - typeof response.data.redirect_uri === "string" && - response.data.redirect_uri.length > 0 - ? response.data.redirect_uri - : null; - - if (!redirectUri) { + // Better Auth returns { redirect: true, url } (both accept and deny) — not { redirect_uri }. + const redirectUrl = resolveConsentRedirectUrl(response.data); + if (!redirectUrl) { toast.error(t("auth.oauth.consent_failed")); return; } - window.location.href = redirectUri; + window.location.href = redirectUrl; } catch { toast.error(t("auth.oauth.consent_failed")); } finally { diff --git a/apps/web/app/(app)/account/authorize/lib/consent-redirect.test.ts b/apps/web/app/(app)/account/authorize/lib/consent-redirect.test.ts new file mode 100644 index 000000000000..2a3c329de2c9 --- /dev/null +++ b/apps/web/app/(app)/account/authorize/lib/consent-redirect.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; +import { resolveConsentRedirectUrl } from "./consent-redirect"; + +describe("resolveConsentRedirectUrl", () => { + test("reads `url` from the real accept shape ({ redirect: true, url })", () => { + expect( + resolveConsentRedirectUrl({ redirect: true, url: "https://client.example/callback?code=abc&state=xyz" }) + ).toBe("https://client.example/callback?code=abc&state=xyz"); + }); + + test("reads the error `url` from the deny shape", () => { + expect( + resolveConsentRedirectUrl({ + redirect: true, + url: "https://client.example/callback?error=access_denied", + }) + ).toBe("https://client.example/callback?error=access_denied"); + }); + + test("allows loopback and custom-scheme redirect targets (native MCP clients)", () => { + expect(resolveConsentRedirectUrl({ url: "http://127.0.0.1:52765/callback?code=abc" })).toBe( + "http://127.0.0.1:52765/callback?code=abc" + ); + expect(resolveConsentRedirectUrl({ url: "cursor://anysphere.cursor-mcp/callback?code=abc" })).toBe( + "cursor://anysphere.cursor-mcp/callback?code=abc" + ); + }); + + test("falls back to `redirect_uri` when only that is present (documented/legacy shape)", () => { + expect(resolveConsentRedirectUrl({ redirect_uri: "https://client.example/cb?code=abc" })).toBe( + "https://client.example/cb?code=abc" + ); + }); + + test("prefers `url` over `redirect_uri` when both are present", () => { + expect( + resolveConsentRedirectUrl({ url: "https://a.example/cb", redirect_uri: "https://b.example/cb" }) + ).toBe("https://a.example/cb"); + }); + + test("returns null for missing/empty/non-object/non-string values (→ toast)", () => { + expect(resolveConsentRedirectUrl({})).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: " " })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: 123 })).toBeNull(); + expect(resolveConsentRedirectUrl(null)).toBeNull(); + expect(resolveConsentRedirectUrl(undefined)).toBeNull(); + expect(resolveConsentRedirectUrl("https://client.example/cb")).toBeNull(); + }); + + test("rejects dangerous schemes (XSS guard for the location.href sink)", () => { + expect(resolveConsentRedirectUrl({ url: "javascript:alert(1)" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: " JavaScript:alert(1)" })).toBeNull(); + // URL parsing normalizes control-char-obfuscated schemes, so this is caught too. + expect(resolveConsentRedirectUrl({ url: "java\tscript:alert(1)" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "data:text/html," })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "file:///etc/passwd" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "blob:https://x/1" })).toBeNull(); + // falls through to the fallback, which is also dangerous → null + expect(resolveConsentRedirectUrl({ url: "javascript:1", redirect_uri: "data:x" })).toBeNull(); + }); + + test("rejects malformed / protocol-relative values (not a well-formed absolute URL)", () => { + expect(resolveConsentRedirectUrl({ url: "//evil.com/path" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "not a url" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "/relative/path" })).toBeNull(); + }); +}); diff --git a/apps/web/app/(app)/account/authorize/lib/consent-redirect.ts b/apps/web/app/(app)/account/authorize/lib/consent-redirect.ts new file mode 100644 index 000000000000..2236256ba495 --- /dev/null +++ b/apps/web/app/(app)/account/authorize/lib/consent-redirect.ts @@ -0,0 +1,37 @@ +/** + * Extract the post-consent redirect URL from a Better Auth `oauth2.consent` response. + * + * On accept, `@better-auth/oauth-provider` delegates to its authorize handler, which for a fetch/JSON + * request returns `{ redirect: true, url: "" }` (see `handleRedirect` in the plugin) — + * NOT `{ redirect_uri }`. The plugin's OpenAPI schema documents `redirect_uri`, which is misleading; + * the runtime field is `url`. Deny returns the same shape with an `error=access_denied` URL. We read + * `url` first, and keep `redirect_uri` as a defensive fallback for the documented/legacy shape. + * + * Security: the returned string is fed to `window.location.href`, so we reject XSS-capable schemes. + * We parse with the `URL` constructor and deny the dangerous protocols rather than allowlisting — native + * MCP clients use arbitrary custom/loopback schemes (`cursor://`, `vscode://`, `http://127.0.0.1:PORT`), + * so the safe set can't be enumerated, and the oauth-provider already validated the target `redirect_uri` + * against the registered client at `/authorize`. Parsing (vs a regex) also normalizes obfuscated schemes + * like `java\tscript:` and rejects malformed / protocol-relative values. + */ +const DANGEROUS_PROTOCOLS = new Set(["javascript:", "data:", "vbscript:", "file:", "blob:"]); + +const asRedirectString = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + let protocol: string; + try { + protocol = new URL(trimmed).protocol; + } catch { + return null; // not a well-formed absolute URL + } + if (DANGEROUS_PROTOCOLS.has(protocol.toLowerCase())) return null; + return trimmed; +}; + +export const resolveConsentRedirectUrl = (data: unknown): string | null => { + if (typeof data !== "object" || data === null) return null; + const record = data as Record; + return asRedirectString(record.url) ?? asRedirectString(record.redirect_uri); +}; From 52004278a5e9cd453b22992445303327315aefc2 Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:42:42 +0200 Subject: [PATCH 6/7] fix(surveys): screen-reader semantics for survey elements (#8567) --- apps/web/playwright/survey.spec.ts | 6 ++-- .../src/components/elements/ranking.tsx | 12 ++++--- packages/surveys/i18n.lock | 2 ++ packages/surveys/locales/ar-EG.json | 2 ++ packages/surveys/locales/da-DK.json | 2 ++ packages/surveys/locales/de-DE.json | 2 ++ packages/surveys/locales/en-US.json | 2 ++ packages/surveys/locales/es-ES.json | 2 ++ packages/surveys/locales/et-EE.json | 2 ++ packages/surveys/locales/fr-FR.json | 2 ++ packages/surveys/locales/hi-IN.json | 2 ++ packages/surveys/locales/hu-HU.json | 2 ++ packages/surveys/locales/it-IT.json | 2 ++ packages/surveys/locales/ja-JP.json | 2 ++ packages/surveys/locales/nl-NL.json | 2 ++ packages/surveys/locales/pt-BR.json | 2 ++ packages/surveys/locales/ro-RO.json | 2 ++ packages/surveys/locales/ru-RU.json | 2 ++ packages/surveys/locales/sv-SE.json | 2 ++ packages/surveys/locales/tr-TR.json | 2 ++ packages/surveys/locales/uz-UZ.json | 2 ++ packages/surveys/locales/zh-Hans-CN.json | 2 ++ .../elements/picture-selection-element.tsx | 6 ++-- .../src/components/general/cal-embed.tsx | 32 +++++++++++++++++-- packages/surveys/src/lib/storage.test.ts | 23 ++++++++++++- packages/surveys/src/lib/storage.ts | 27 ++++++++++++++++ 26 files changed, 133 insertions(+), 13 deletions(-) diff --git a/apps/web/playwright/survey.spec.ts b/apps/web/playwright/survey.spec.ts index 2e9153a62fcd..d81082db3804 100644 --- a/apps/web/playwright/survey.spec.ts +++ b/apps/web/playwright/survey.spec.ts @@ -20,8 +20,10 @@ test.beforeEach(async ({ page }) => { await helper.mockStorageUploads(page); }); -const firstPictureChoiceAlt = "logo-transparent.png"; -const secondPictureChoiceAlt = "android-chrome-192x192.png"; +// The rendered alt is the human-readable form of the file name (decoded, no +// extension or separator noise) — see getImageAltFromUrl in @formbricks/surveys. +const firstPictureChoiceAlt = "logo transparent"; +const secondPictureChoiceAlt = "android chrome 192x192"; const selectPictureChoice = async (pictureSelectQuestion: Locator, choiceAlt: string) => { const choiceImage = pictureSelectQuestion.getByRole("img", { name: choiceAlt }); diff --git a/packages/survey-ui/src/components/elements/ranking.tsx b/packages/survey-ui/src/components/elements/ranking.tsx index 4cf9813dfdf4..0dfe6daba0b8 100644 --- a/packages/survey-ui/src/components/elements/ranking.tsx +++ b/packages/survey-ui/src/components/elements/ranking.tsx @@ -75,7 +75,7 @@ function RankingItem({ const displayNumber = isRanked ? rankIndex + 1 : undefined; return ( -
) : null} -
+ ); } @@ -227,7 +227,11 @@ function Ranking({
Ranking options -
+ {/* Semantic ordered list so screen readers announce rank position and count; + role="list" is kept explicitly because list-style removal (Tailwind preflight) + makes Safari/VoiceOver drop implicit list semantics. */} + {/* eslint-disable-next-line jsx-a11y/no-redundant-roles -- Safari/VoiceOver needs the explicit role once list-style is none */} +
    {allItems.map((item) => ( ))} -
+
diff --git a/packages/surveys/i18n.lock b/packages/surveys/i18n.lock index 0aa3867f18ef..8074a563d05f 100644 --- a/packages/surveys/i18n.lock +++ b/packages/surveys/i18n.lock @@ -11,6 +11,7 @@ checksums: common/next: 89ddbcf710eba274963494f312bdc8a9 common/no_results_found: 5518f2865757dc73900aa03ef8be6934 common/open_in_new_tab: 6844e4922a7a40a7ee25c10ea109cdeb + common/option_number: 9144a491be19258a1ea077a0e12892e6 common/people_responded: b685fb877090d8658db724ad07a0dbd8 common/please_retry_now_or_try_again_later: 949a3841e2eb01fa249790a42bf23aa5 common/powered_by: 6b6f88e2fa5a1ecec6cebf813abaeebb @@ -23,6 +24,7 @@ checksums: common/response_saved_offline: 7c06127ffc49fe30c5c1d8ca792c7a0d common/retry: 6e44d18639560596569a1278f9c83676 common/retrying: 40989361ea5f6b95897b95ac928b5bd9 + common/scheduling_calendar: 043c92144cbaa0fbe000d5c2ea234b9a common/scroll_to_bottom: 9481586f42c7c8b1b91c220346e1e630 common/search: fe877a75eac472fc5b188c135c78a558 common/select_option: d68a0fb9afd0817dc31b3e9cb11855cb diff --git a/packages/surveys/locales/ar-EG.json b/packages/surveys/locales/ar-EG.json index 6a6222c732c8..9102bb22bcd0 100644 --- a/packages/surveys/locales/ar-EG.json +++ b/packages/surveys/locales/ar-EG.json @@ -10,6 +10,7 @@ "next": "التالي", "no_results_found": "لم يتم العثور على نتائج", "open_in_new_tab": "فتح في علامة تبويب جديدة", + "option_number": "الخيار {number}", "people_responded": "{count, plural, one {شخص واحد استجاب} two {شخصان استجابا} few {{count} أشخاص استجابوا} many {{count} شخصًا استجابوا} other {{count} شخص استجابوا}}", "please_retry_now_or_try_again_later": "يرجى إعادة المحاولة الآن أو المحاولة مرة أخرى لاحقًا.", "powered_by": "مشغل بواسطة", @@ -22,6 +23,7 @@ "response_saved_offline": "لم يتم إرسال ردك بعد. سيتم إرساله تلقائيًا بمجرد عودة الاتصال بالإنترنت.", "retry": "إعادة المحاولة", "retrying": "إعادة المحاولة...", + "scheduling_calendar": "تقويم الجدولة", "scroll_to_bottom": "التمرير إلى الأسفل", "search": "بحث...", "select_option": "اختر خيارًا", diff --git a/packages/surveys/locales/da-DK.json b/packages/surveys/locales/da-DK.json index fa4ac2db054f..d5cc8625f14e 100644 --- a/packages/surveys/locales/da-DK.json +++ b/packages/surveys/locales/da-DK.json @@ -10,6 +10,7 @@ "next": "Næste", "no_results_found": "Ingen resultater fundet", "open_in_new_tab": "Åbn i ny fane", + "option_number": "Mulighed {number}", "people_responded": "{count, plural, one {1 person har svaret} other {{count} personer har svaret}}", "please_retry_now_or_try_again_later": "Prøv igen nu eller prøv senere.", "powered_by": "Drevet af", @@ -22,6 +23,7 @@ "response_saved_offline": "Dit svar er ikke sendt endnu. Det bliver automatisk indsendt, når du er online igen.", "retry": "Prøv igen", "retrying": "Prøver igen…", + "scheduling_calendar": "Planlægningskalender", "scroll_to_bottom": "Rul til bunden", "search": "Søg...", "select_option": "Vælg en mulighed", diff --git a/packages/surveys/locales/de-DE.json b/packages/surveys/locales/de-DE.json index 2b12e2b8a6a8..166c67b8291c 100644 --- a/packages/surveys/locales/de-DE.json +++ b/packages/surveys/locales/de-DE.json @@ -10,6 +10,7 @@ "next": "Weiter", "no_results_found": "Keine Ergebnisse gefunden", "open_in_new_tab": "In neuem Tab öffnen", + "option_number": "Option {number}", "people_responded": "{count, plural, one {1 Person hat geantwortet} other {{count} Personen haben geantwortet}}", "please_retry_now_or_try_again_later": "Bitte versuchen Sie es jetzt erneut oder später noch einmal.", "powered_by": "Bereitgestellt von", @@ -22,6 +23,7 @@ "response_saved_offline": "Deine Antwort wurde noch nicht gesendet. Sie wird automatisch übermittelt, sobald du wieder online bist.", "retry": "Wiederholen", "retrying": "Erneuter Versuch...", + "scheduling_calendar": "Terminkalender", "scroll_to_bottom": "Nach unten scrollen", "search": "Suchen...", "select_option": "Wähle eine Option", diff --git a/packages/surveys/locales/en-US.json b/packages/surveys/locales/en-US.json index 77bfe4fdc564..83774df44ddb 100644 --- a/packages/surveys/locales/en-US.json +++ b/packages/surveys/locales/en-US.json @@ -10,6 +10,7 @@ "next": "Next", "no_results_found": "No results found", "open_in_new_tab": "Open in new tab", + "option_number": "Option {number}", "people_responded": "{count, plural, one {1 person responded} other {{count} people responded}}", "please_retry_now_or_try_again_later": "Please retry now or try again later.", "powered_by": "Powered by", @@ -22,6 +23,7 @@ "response_saved_offline": "Your response hasn't been sent yet. It will be submitted automatically once you're back online.", "retry": "Retry", "retrying": "Retrying…", + "scheduling_calendar": "Scheduling calendar", "scroll_to_bottom": "Scroll to bottom", "search": "Search...", "select_option": "Select an option", diff --git a/packages/surveys/locales/es-ES.json b/packages/surveys/locales/es-ES.json index dd40f91241eb..101837efc75b 100644 --- a/packages/surveys/locales/es-ES.json +++ b/packages/surveys/locales/es-ES.json @@ -10,6 +10,7 @@ "next": "Siguiente", "no_results_found": "No se encontraron resultados", "open_in_new_tab": "Abrir en nueva pestaña", + "option_number": "Opción {number}", "people_responded": "{count, plural, one {1 persona respondió} other {{count} personas respondieron}}", "please_retry_now_or_try_again_later": "Por favor, inténtalo ahora o prueba más tarde.", "powered_by": "Desarrollado por", @@ -22,6 +23,7 @@ "response_saved_offline": "Tu respuesta aún no se ha enviado. Se enviará automáticamente cuando vuelvas a estar en línea.", "retry": "Reintentar", "retrying": "Reintentando...", + "scheduling_calendar": "Calendario de programación", "scroll_to_bottom": "Desplazarse hacia abajo", "search": "Buscar...", "select_option": "Selecciona una opción", diff --git a/packages/surveys/locales/et-EE.json b/packages/surveys/locales/et-EE.json index 9258ddc69da9..c8cec8385df2 100644 --- a/packages/surveys/locales/et-EE.json +++ b/packages/surveys/locales/et-EE.json @@ -10,6 +10,7 @@ "next": "Edasi", "no_results_found": "Tulemusi ei leitud", "open_in_new_tab": "Ava uuel vahelehel", + "option_number": "Valik {number}", "people_responded": "{count, plural, one {1 inimene vastas} other {{count} inimest vastas}}", "please_retry_now_or_try_again_later": "Palun proovi uuesti kohe või hiljem.", "powered_by": "Teenust pakub", @@ -22,6 +23,7 @@ "response_saved_offline": "Sinu vastust pole veel saadetud. See edastatakse automaatselt, kui oled taas võrgus.", "retry": "Proovi uuesti", "retrying": "Proovin uuesti…", + "scheduling_calendar": "Planeerimiskalender", "scroll_to_bottom": "Keri alla", "search": "Otsi...", "select_option": "Vali variant", diff --git a/packages/surveys/locales/fr-FR.json b/packages/surveys/locales/fr-FR.json index 159c7b61286a..f94ddc9ba379 100644 --- a/packages/surveys/locales/fr-FR.json +++ b/packages/surveys/locales/fr-FR.json @@ -10,6 +10,7 @@ "next": "Suivant", "no_results_found": "Aucun résultat trouvé", "open_in_new_tab": "Ouvrir dans un nouvel onglet", + "option_number": "Option {number}", "people_responded": "{count, plural, one {1 personne a répondu} other {{count} personnes ont répondu}}", "please_retry_now_or_try_again_later": "Veuillez réessayer maintenant ou réessayer plus tard.", "powered_by": "Propulsé par", @@ -22,6 +23,7 @@ "response_saved_offline": "Votre réponse n'a pas encore été envoyée. Elle sera soumise automatiquement dès que vous serez de nouveau en ligne.", "retry": "Réessayer", "retrying": "Nouvelle tentative...", + "scheduling_calendar": "Calendrier de planification", "scroll_to_bottom": "Faire défiler vers le bas", "search": "Rechercher...", "select_option": "Sélectionner une option", diff --git a/packages/surveys/locales/hi-IN.json b/packages/surveys/locales/hi-IN.json index ef6a5844efe9..a8a67caa38c6 100644 --- a/packages/surveys/locales/hi-IN.json +++ b/packages/surveys/locales/hi-IN.json @@ -10,6 +10,7 @@ "next": "अगला", "no_results_found": "कोई परिणाम नहीं मिला", "open_in_new_tab": "नए टैब में खोलें", + "option_number": "विकल्प {number}", "people_responded": "{count, plural, one {1 व्यक्ति ने जवाब दिया} other {{count} लोगों ने जवाब दिया}}", "please_retry_now_or_try_again_later": "कृपया अभी पुनः प्रयास करें या बाद में फिर से प्रयास करें।", "powered_by": "द्वारा संचालित", @@ -22,6 +23,7 @@ "response_saved_offline": "आपका जवाब अभी तक नहीं भेजा गया है। जब आप ऑनलाइन होंगे तो यह अपने आप सबमिट हो जाएगा।", "retry": "पुनः प्रयास करें", "retrying": "पुनः प्रयास कर रहे हैं...", + "scheduling_calendar": "शेड्यूलिंग कैलेंडर", "scroll_to_bottom": "नीचे स्क्रॉल करें", "search": "खोजें...", "select_option": "एक विकल्प चुनें", diff --git a/packages/surveys/locales/hu-HU.json b/packages/surveys/locales/hu-HU.json index d6efd20e8e28..e0f1948b492f 100644 --- a/packages/surveys/locales/hu-HU.json +++ b/packages/surveys/locales/hu-HU.json @@ -10,6 +10,7 @@ "next": "Következő", "no_results_found": "Nincsenek találatok", "open_in_new_tab": "Megnyitás új lapon", + "option_number": "Opció {number}", "people_responded": "{count, plural, one {1 személy válaszolt} other {{count} személy válaszolt}}", "please_retry_now_or_try_again_later": "Próbálkozzon újra most, vagy próbálja meg később újra.", "powered_by": "A gépházban:", @@ -22,6 +23,7 @@ "response_saved_offline": "A válasza még nem lett elküldve. Automatikusan elküldésre kerül, amint újra elérhető lesz.", "retry": "Újrapróbálkozás", "retrying": "Újrapróbálkozás…", + "scheduling_calendar": "Ütemezési naptár", "scroll_to_bottom": "Görgetés az aljára", "search": "Keresés…", "select_option": "Lehetőség kiválasztása", diff --git a/packages/surveys/locales/it-IT.json b/packages/surveys/locales/it-IT.json index e8cf77b2be81..d4f0987c5c16 100644 --- a/packages/surveys/locales/it-IT.json +++ b/packages/surveys/locales/it-IT.json @@ -10,6 +10,7 @@ "next": "Avanti", "no_results_found": "Nessun risultato trovato", "open_in_new_tab": "Apri in una nuova scheda", + "option_number": "Opzione {number}", "people_responded": "{count, plural, one {1 persona ha risposto} other {{count} persone hanno risposto}}", "please_retry_now_or_try_again_later": "Riprova ora o più tardi.", "powered_by": "Offerto da", @@ -22,6 +23,7 @@ "response_saved_offline": "La tua risposta non è stata ancora inviata. Verrà inviata automaticamente quando tornerai online.", "retry": "Riprova", "retrying": "Riprovando...", + "scheduling_calendar": "Calendario di pianificazione", "scroll_to_bottom": "Scorri verso il basso", "search": "Cerca...", "select_option": "Seleziona un'opzione", diff --git a/packages/surveys/locales/ja-JP.json b/packages/surveys/locales/ja-JP.json index e93e274dbb0f..1de5f92c4be6 100644 --- a/packages/surveys/locales/ja-JP.json +++ b/packages/surveys/locales/ja-JP.json @@ -10,6 +10,7 @@ "next": "次へ", "no_results_found": "結果が見つかりません", "open_in_new_tab": "新しいタブで開く", + "option_number": "オプション {number}", "people_responded": "{count, plural, other {{count}人が回答しました}}", "please_retry_now_or_try_again_later": "今すぐ再試行するか、後でもう一度お試しください。", "powered_by": "提供:", @@ -22,6 +23,7 @@ "response_saved_offline": "あなたの回答はまだ送信されていません。オンラインに戻ると自動的に送信されます。", "retry": "再試行", "retrying": "再試行中...", + "scheduling_calendar": "スケジュールカレンダー", "scroll_to_bottom": "下にスクロール", "search": "検索...", "select_option": "オプションを選択", diff --git a/packages/surveys/locales/nl-NL.json b/packages/surveys/locales/nl-NL.json index 412c357f8068..45ddfa9a3934 100644 --- a/packages/surveys/locales/nl-NL.json +++ b/packages/surveys/locales/nl-NL.json @@ -10,6 +10,7 @@ "next": "Volgende", "no_results_found": "Geen resultaten gevonden", "open_in_new_tab": "Openen in nieuw tabblad", + "option_number": "Optie {number}", "people_responded": "{count, plural, one {1 persoon heeft gereageerd} other {{count} mensen hebben gereageerd}}", "please_retry_now_or_try_again_later": "Probeer het nu opnieuw of probeer het later opnieuw.", "powered_by": "Aangedreven door", @@ -22,6 +23,7 @@ "response_saved_offline": "Je reactie is nog niet verzonden. Deze wordt automatisch verstuurd zodra je weer online bent.", "retry": "Opnieuw proberen", "retrying": "Opnieuw proberen...", + "scheduling_calendar": "Planningskalender", "scroll_to_bottom": "Naar beneden scrollen", "search": "Zoeken...", "select_option": "Selecteer een optie", diff --git a/packages/surveys/locales/pt-BR.json b/packages/surveys/locales/pt-BR.json index 605f8cf8b027..8b3e80827d4a 100644 --- a/packages/surveys/locales/pt-BR.json +++ b/packages/surveys/locales/pt-BR.json @@ -10,6 +10,7 @@ "next": "Próximo", "no_results_found": "Nenhum resultado encontrado", "open_in_new_tab": "Abrir em nova aba", + "option_number": "Opção {number}", "people_responded": "{count, plural, one {1 pessoa respondeu} other {{count} pessoas responderam}}", "please_retry_now_or_try_again_later": "Por favor, tente novamente agora ou mais tarde.", "powered_by": "Desenvolvido por", @@ -22,6 +23,7 @@ "response_saved_offline": "A tua resposta ainda não foi enviada. Será submetida automaticamente quando voltares a estar online.", "retry": "Tentar novamente", "retrying": "Tentando novamente...", + "scheduling_calendar": "Calendário de agendamento", "scroll_to_bottom": "Rolar para baixo", "search": "Pesquisar...", "select_option": "Selecione uma opção", diff --git a/packages/surveys/locales/ro-RO.json b/packages/surveys/locales/ro-RO.json index 1c625e76f23e..51db1c05ce87 100644 --- a/packages/surveys/locales/ro-RO.json +++ b/packages/surveys/locales/ro-RO.json @@ -10,6 +10,7 @@ "next": "Următorul", "no_results_found": "Nu s-au găsit rezultate", "open_in_new_tab": "Deschide într-o filă nouă", + "option_number": "Opțiunea {number}", "people_responded": "{count, plural, one {1 persoană a răspuns} other {{count} persoane au răspuns}}", "please_retry_now_or_try_again_later": "Te rugăm să încerci din nou acum sau mai târziu.", "powered_by": "Susținut de", @@ -22,6 +23,7 @@ "response_saved_offline": "Răspunsul tău nu a fost trimis încă. Va fi transmis automat când vei reveni online.", "retry": "Reîncearcă", "retrying": "Se reîncearcă...", + "scheduling_calendar": "Calendar de programare", "scroll_to_bottom": "Derulează în jos", "search": "Căutare...", "select_option": "Selectează o opțiune", diff --git a/packages/surveys/locales/ru-RU.json b/packages/surveys/locales/ru-RU.json index 2c463b4ce84d..4fa080577e38 100644 --- a/packages/surveys/locales/ru-RU.json +++ b/packages/surveys/locales/ru-RU.json @@ -10,6 +10,7 @@ "next": "Далее", "no_results_found": "Результатов не найдено", "open_in_new_tab": "Открыть в новой вкладке", + "option_number": "Вариант {number}", "people_responded": "{count, plural, one {1 человек ответил} other {{count} человека ответили}}", "please_retry_now_or_try_again_later": "Пожалуйста, повторите попытку сейчас или попробуйте позже.", "powered_by": "Работает на основе", @@ -22,6 +23,7 @@ "response_saved_offline": "Твой ответ еще не отправлен. Он будет отправлен автоматически, как только ты снова будешь онлайн.", "retry": "Повторить", "retrying": "Повторная попытка...", + "scheduling_calendar": "Календарь планирования", "scroll_to_bottom": "Прокрутить вниз", "search": "Поиск...", "select_option": "Выбери вариант", diff --git a/packages/surveys/locales/sv-SE.json b/packages/surveys/locales/sv-SE.json index 49b4d0f9b23b..06d3ad7ac102 100644 --- a/packages/surveys/locales/sv-SE.json +++ b/packages/surveys/locales/sv-SE.json @@ -10,6 +10,7 @@ "next": "Nästa", "no_results_found": "Inga resultat hittades", "open_in_new_tab": "Öppna i ny flik", + "option_number": "Alternativ {number}", "people_responded": "{count, plural, one {1 person har svarat} other {{count} personer har svarat}}", "please_retry_now_or_try_again_later": "Försök igen nu eller försök igen senare.", "powered_by": "Drivs av", @@ -22,6 +23,7 @@ "response_saved_offline": "Ditt svar har inte skickats än. Det kommer att skickas automatiskt när du är online igen.", "retry": "Försök igen", "retrying": "Försöker igen...", + "scheduling_calendar": "Schemaläggningskalender", "scroll_to_bottom": "Rulla längst ner", "search": "Sök...", "select_option": "Välj ett alternativ", diff --git a/packages/surveys/locales/tr-TR.json b/packages/surveys/locales/tr-TR.json index 244e19a5ff58..d375f58f8449 100644 --- a/packages/surveys/locales/tr-TR.json +++ b/packages/surveys/locales/tr-TR.json @@ -10,6 +10,7 @@ "next": "Sonraki", "no_results_found": "Sonuç bulunamadı", "open_in_new_tab": "Yeni sekmede aç", + "option_number": "Seçenek {number}", "people_responded": "{count, plural, one {1 kişi yanıtladı} other {{count} kişi yanıtladı}}", "please_retry_now_or_try_again_later": "Lütfen şimdi tekrar deneyin veya daha sonra tekrar deneyin.", "powered_by": "Destekleyen", @@ -22,6 +23,7 @@ "response_saved_offline": "Yanıtın henüz gönderilmedi. İnternete geri döndüğünde otomatik olarak iletilecek.", "retry": "Yeniden dene", "retrying": "Yeniden deneniyor…", + "scheduling_calendar": "Planlama takvimi", "scroll_to_bottom": "Aşağı kaydır", "search": "Ara...", "select_option": "Bir seçenek seçin", diff --git a/packages/surveys/locales/uz-UZ.json b/packages/surveys/locales/uz-UZ.json index be98ea117c59..e40aae76d8bb 100644 --- a/packages/surveys/locales/uz-UZ.json +++ b/packages/surveys/locales/uz-UZ.json @@ -10,6 +10,7 @@ "next": "Keyingisi", "no_results_found": "Natijalar topilmadi", "open_in_new_tab": "Yangi oynada ochish", + "option_number": "{number}-variant", "people_responded": "{count, plural, one {1 kishi javob berdi} other {{count} kishi javob berdi}}", "please_retry_now_or_try_again_later": "Iltimos, hozir qayta urinib ko‘ring yoki keyinroq urinib ko‘ring.", "powered_by": "Quvvatlanadi", @@ -22,6 +23,7 @@ "response_saved_offline": "Javobingiz hali yuborilmadi. Internetga ulanganingizdan so'ng avtomatik ravishda yuboriladi.", "retry": "Qayta urinib ko'ring", "retrying": "Qayta urinilmoqda...", + "scheduling_calendar": "Rejalashtirish taqvimi", "scroll_to_bottom": "Pastga aylantirish", "search": "Qidirish...", "select_option": "Variantni tanla", diff --git a/packages/surveys/locales/zh-Hans-CN.json b/packages/surveys/locales/zh-Hans-CN.json index 3ca88ff81b1f..4f0249ccb233 100644 --- a/packages/surveys/locales/zh-Hans-CN.json +++ b/packages/surveys/locales/zh-Hans-CN.json @@ -10,6 +10,7 @@ "next": "下一步", "no_results_found": "未找到结果", "open_in_new_tab": "在新标签页中打开", + "option_number": "选项 {number}", "people_responded": "{count, plural, one {1 人已回应} other {{count} 人已回应}}", "please_retry_now_or_try_again_later": "请立即重试或稍后再试。", "powered_by": "技术支持", @@ -22,6 +23,7 @@ "response_saved_offline": "你的回复尚未发送。一旦恢复网络连接,将自动提交。", "retry": "重试", "retrying": "重试中...", + "scheduling_calendar": "日程安排日历", "scroll_to_bottom": "滚动到底部", "search": "搜索...", "select_option": "请选择一个选项", diff --git a/packages/surveys/src/components/elements/picture-selection-element.tsx b/packages/surveys/src/components/elements/picture-selection-element.tsx index e1194fc9bb20..04790ea68761 100644 --- a/packages/surveys/src/components/elements/picture-selection-element.tsx +++ b/packages/surveys/src/components/elements/picture-selection-element.tsx @@ -4,7 +4,7 @@ import { PictureSelect, type PictureSelectOption } from "@formbricks/survey-ui"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements"; import { getLocalizedValue } from "@/lib/i18n"; -import { getOriginalFileNameFromUrl } from "@/lib/storage"; +import { getImageAltFromUrl } from "@/lib/storage"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface PictureSelectionProps { @@ -37,10 +37,10 @@ export function PictureSelectionElement({ const { t } = useTranslation(); useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent); // Convert choices to PictureSelectOption format - const options: PictureSelectOption[] = element.choices.map((choice) => ({ + const options: PictureSelectOption[] = element.choices.map((choice, index) => ({ id: choice.id, imageUrl: choice.imageUrl, - alt: getOriginalFileNameFromUrl(choice.imageUrl), + alt: getImageAltFromUrl(choice.imageUrl) || t("common.option_number", { number: index + 1 }), })); // Convert value from string[] to string | string[] based on allowMulti diff --git a/packages/surveys/src/components/general/cal-embed.tsx b/packages/surveys/src/components/general/cal-embed.tsx index a38861138ef2..1464579ed30f 100644 --- a/packages/surveys/src/components/general/cal-embed.tsx +++ b/packages/surveys/src/components/general/cal-embed.tsx @@ -1,5 +1,6 @@ import snippet from "@calcom/embed-snippet"; import { useEffect, useMemo } from "preact/hooks"; +import { useTranslation } from "react-i18next"; import { type TSurveyCalElement } from "@formbricks/types/surveys/elements"; import { cn } from "@/lib/utils"; @@ -9,6 +10,12 @@ interface CalEmbedProps { } export function CalEmbed({ element, onSuccessfulBooking }: CalEmbedProps) { + const { t } = useTranslation(); + const iframeTitle = t("common.scheduling_calendar"); + // Per-element id so multiple Cal questions on one page don't all inject into the + // first container (and so the iframe-title lookup below targets the right one). + const containerId = `cal-embed-${element.id}`; + const cal = useMemo(() => { const calInline = snippet("https://cal.com/embed.js"); @@ -48,14 +55,33 @@ export function CalEmbed({ element, onSuccessfulBooking }: CalEmbedProps) { }); cal("init", { calOrigin: element.calHost ? `https://${element.calHost}` : "https://cal.com" }); cal("inline", { - elementOrSelector: "#cal-embed", + elementOrSelector: `#${containerId}`, calLink: element.calUserName, }); - }, [cal, element.calHost, element.calUserName]); + + // The snippet injects the iframe asynchronously without a title, so screen + // readers announce it as just "iframe". Title it as soon as it appears. + const embedContainer = document.getElementById(containerId); + if (!embedContainer) return; + const titleIframe = (): boolean => { + const iframe = embedContainer.querySelector("iframe"); + if (iframe && !iframe.title) iframe.title = iframeTitle; + return Boolean(iframe); + }; + if (!titleIframe()) { + const observer = new MutationObserver(() => { + if (titleIframe()) observer.disconnect(); + }); + observer.observe(embedContainer, { childList: true, subtree: true }); + return () => { + observer.disconnect(); + }; + } + }, [cal, element.calHost, element.calUserName, iframeTitle, containerId]); return (
-
+
); } diff --git a/packages/surveys/src/lib/storage.test.ts b/packages/surveys/src/lib/storage.test.ts index 0ccfa4792a4b..38a98783007f 100644 --- a/packages/surveys/src/lib/storage.test.ts +++ b/packages/surveys/src/lib/storage.test.ts @@ -1,5 +1,26 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { getOriginalFileNameFromUrl } from "./storage"; +import { getImageAltFromUrl, getOriginalFileNameFromUrl } from "./storage"; + +describe("getImageAltFromUrl", () => { + test("decodes URL-encoded file names and strips the extension", () => { + const url = "https://example.com/storage/ChatGPT%20Image%20Jun%205%2C%202026--fid--abc123.png"; + expect(getImageAltFromUrl(url)).toBe("ChatGPT Image Jun 5, 2026"); + }); + + test("handles double-encoded file names", () => { + const url = "https://example.com/storage/My%2520Holiday%2520Photo.jpeg"; + expect(getImageAltFromUrl(url)).toBe("My Holiday Photo"); + }); + + test("replaces separator noise with spaces", () => { + const url = "https://example.com/storage/team_photo-2026_final.webp"; + expect(getImageAltFromUrl(url)).toBe("team photo 2026 final"); + }); + + test("returns empty string when nothing readable is left", () => { + expect(getImageAltFromUrl("https://example.com/files/path/")).toBe(""); + }); +}); describe("getOriginalFileNameFromUrl", () => { let consoleErrorSpy: any; // Explicitly 'any' to avoid type issues for now diff --git a/packages/surveys/src/lib/storage.ts b/packages/surveys/src/lib/storage.ts index 5b0a90297710..db31ecdb0b7d 100644 --- a/packages/surveys/src/lib/storage.ts +++ b/packages/surveys/src/lib/storage.ts @@ -1,3 +1,30 @@ +/** + * Human-readable alt text derived from a storage URL: the decoded file name + * without extension or separator noise, so screen readers don't spell out + * strings like "ChatGPT%20Image%20Jun%205.png". Returns "" when nothing + * readable is left (callers should fall back to a localized label). + */ +export const getImageAltFromUrl = (fileURL: string): string => { + let name = getOriginalFileNameFromUrl(fileURL); + + // Stored URLs are sometimes double-encoded; decode until stable. + for (let i = 0; i < 3; i++) { + try { + const decoded = decodeURIComponent(name); + if (decoded === name) break; + name = decoded; + } catch { + break; + } + } + + return name + .replace(/\.[a-z0-9]+$/i, "") + .replace(/[-_+]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +}; + export const getOriginalFileNameFromUrl = (fileURL: string): string => { try { const fileNameFromURL = fileURL.startsWith("/storage/") From f9675dcdd8ed44d10ae343575398038413b7213d Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:01:38 +0200 Subject: [PATCH 7/7] refactor(surveys): WCAG-compliant keyboard and focus model for survey elements (#8566) Co-authored-by: Johannes --- .gitignore | 1 + apps/web/playwright/survey-keyboard.spec.ts | 340 ++++++++++++++++++ packages/survey-ui/package.json | 2 + .../src/components/elements/multi-select.tsx | 26 +- .../survey-ui/src/components/elements/nps.tsx | 35 +- .../components/elements/picture-select.tsx | 78 ++-- .../src/components/elements/rating.tsx | 19 +- .../src/components/elements/single-select.tsx | 55 ++- .../components/general/dropdown-search.tsx | 58 ++- .../src/lib/use-roving-radio-group.test.ts | 210 +++++++++++ .../src/lib/use-roving-radio-group.ts | 114 ++++++ packages/survey-ui/src/styles/globals.css | 50 ++- .../components/general/block-conditional.tsx | 68 +++- .../src/components/general/ending-card.tsx | 15 +- .../surveys/src/components/general/survey.tsx | 23 +- pnpm-lock.yaml | 22 +- 16 files changed, 1034 insertions(+), 82 deletions(-) create mode 100644 apps/web/playwright/survey-keyboard.spec.ts create mode 100644 packages/survey-ui/src/lib/use-roving-radio-group.test.ts create mode 100644 packages/survey-ui/src/lib/use-roving-radio-group.ts diff --git a/.gitignore b/.gitignore index c0218e30e668..34cfd559132d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ yarn-error.log* /playwright/.cache/ # project specific +.local/ packages/lib/uploads apps/web/public/js packages/database/generated diff --git a/apps/web/playwright/survey-keyboard.spec.ts b/apps/web/playwright/survey-keyboard.spec.ts new file mode 100644 index 000000000000..2e2466b8e64c --- /dev/null +++ b/apps/web/playwright/survey-keyboard.spec.ts @@ -0,0 +1,340 @@ +import { createId } from "@paralleldrive/cuid2"; +import { type Page, expect } from "@playwright/test"; +import { prisma } from "@formbricks/database"; +import { Prisma } from "@formbricks/database/prisma"; +import { type TSurveyEnding } from "@formbricks/types/surveys/types"; +import { transformQuestionsToBlocks } from "@/app/lib/api/survey-transformation"; +import { test } from "./lib/fixtures"; + +/** + * Keyboard interaction gate for the rendered link survey (ENG-1779). + * + * Covers the APG-aligned keyboard model of the survey player: + * - radio-scale elements (single-select / NPS): one Tab stop, arrow keys move + * focus WITHOUT selecting, Space/Enter select — so auto-progress can never + * fire while a keyboard user is still browsing the options; + * - select dropdowns: ArrowDown moves from the embedded search input into the + * options in both variants (previously a focus trap); + * - focus management: the new card's first control is focused after navigation, + * and the first invalid control is focused after a failed submit. + */ + +type I18n = { default: string }; +const i18nValue = (value: string): I18n => ({ default: value }); + +const SINGLE_CHOICES = ["Free", "Pro", "Enterprise"]; +const DROPDOWN_CHOICES = ["Berlin", "Paris", "Madrid", "Lisbon", "Vienna"]; +const MULTI_CHOICES = ["Surveys", "Contacts", "Integrations", "Webhooks", "API"]; +const ENDING_HEADLINE = "Thanks, keyboard friend!"; + +/** Auto-progress fires 350ms after an explicit selection; wait longer to prove a non-event. */ +const AUTO_PROGRESS_SETTLE_MS = 900; + +const buildQuestions = () => [ + { + id: createId(), + type: "multipleChoiceSingle", + headline: i18nValue("Which plan are you on?"), + required: true, + choices: SINGLE_CHOICES.map((label) => ({ id: createId(), label: i18nValue(label) })), + }, + { + id: createId(), + type: "nps", + headline: i18nValue("How likely are you to recommend us?"), + required: true, + lowerLabel: i18nValue("Not likely"), + upperLabel: i18nValue("Very likely"), + }, + { + id: createId(), + type: "multipleChoiceSingle", + displayType: "dropdown", + headline: i18nValue("Which city do you work from?"), + required: true, + choices: DROPDOWN_CHOICES.map((label) => ({ id: createId(), label: i18nValue(label) })), + }, + { + id: createId(), + type: "multipleChoiceMulti", + displayType: "dropdown", + headline: i18nValue("Which features do you use?"), + required: true, + choices: MULTI_CHOICES.map((label) => ({ id: createId(), label: i18nValue(label) })), + }, + { + id: createId(), + type: "openText", + headline: i18nValue("Anything else to add?"), + required: true, + inputType: "text", + charLimit: { enabled: false }, + }, +]; + +type TLegacyQuestions = Parameters[0]; + +const seedKeyboardSurvey = async (workspaceId: string, createdBy: string): Promise => { + const endings = [ + { + id: createId(), + type: "endScreen" as const, + headline: i18nValue(ENDING_HEADLINE), + subheader: i18nValue("We appreciate your feedback."), + }, + ] as unknown as TSurveyEnding[]; + const blocks = transformQuestionsToBlocks(buildQuestions() as unknown as TLegacyQuestions, endings); + + const survey = await prisma.survey.create({ + data: { + workspaceId, + createdBy, + name: "Keyboard interaction survey", + type: "link", + status: "inProgress", + isAutoProgressingEnabled: true, + welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false }, + blocks: blocks as unknown as Prisma.InputJsonValue[], + endings: endings as unknown as Prisma.InputJsonValue[], + }, + select: { id: true }, + }); + return survey.id; +}; + +/** + * Separate single-question survey for the picture-selection roving check (it + * auto-progresses, so it gets its own fixture instead of a slot in the walk). + * Public assets always resolve and satisfy ZStorageUrl (same trick as the axe + * suite's picture card). + */ +const seedPictureSurvey = async ( + workspaceId: string, + createdBy: string, + baseURL: string +): Promise => { + const endings = [ + { + id: createId(), + type: "endScreen" as const, + headline: i18nValue(ENDING_HEADLINE), + subheader: i18nValue("We appreciate your feedback."), + }, + ] as unknown as TSurveyEnding[]; + const questions = [ + { + id: createId(), + type: "pictureSelection", + headline: i18nValue("Pick the image you prefer"), + required: true, + allowMulti: false, + choices: [ + { id: createId(), imageUrl: new URL("/logo-transparent.png", baseURL).toString() }, + { id: createId(), imageUrl: new URL("/favicon/android-chrome-192x192.png", baseURL).toString() }, + ], + }, + ]; + const blocks = transformQuestionsToBlocks(questions as unknown as TLegacyQuestions, endings); + + const survey = await prisma.survey.create({ + data: { + workspaceId, + createdBy, + name: "Keyboard picture-selection survey", + type: "link", + status: "inProgress", + isAutoProgressingEnabled: true, + welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false }, + blocks: blocks as unknown as Prisma.InputJsonValue[], + endings: endings as unknown as Prisma.InputJsonValue[], + }, + select: { id: true }, + }); + return survey.id; +}; + +// Off-screen stacked cards render a dummy copy of the nav button with tabindex="-1"; +// only the current card's button is focusable. +const navButton = (page: Page, name: string) => + page.getByRole("button", { name }).and(page.locator('[tabindex="0"]')); + +const activeRadio = (page: Page) => + page.evaluate(() => { + const a = document.activeElement as HTMLInputElement | null; + const isRadio = a?.tagName === "INPUT" && a.type === "radio"; + // Scope the checked lookup to the focused radio's own group: an off-screen + // previous card may still hold its answered (checked) radio mid-transition. + const checked = isRadio + ? Array.from(document.getElementsByName(a.name)).find( + (el): el is HTMLInputElement => el instanceof HTMLInputElement && el.checked + ) + : undefined; + return { + isRadio, + focusedValue: a?.value ?? null, + checkedValue: checked?.value ?? null, + }; + }); + +/** + * Asserts a non-event: auto-progress must NOT fire while the user is only + * browsing. The only way to prove nothing happens is to outwait the trigger + * window (350ms submit delay plus margin) — there is no observable condition + * to synchronize on, hence the deliberate fixed wait. + */ +const settleAutoProgressWindow = async (page: Page): Promise => { + await page.waitForTimeout(AUTO_PROGRESS_SETTLE_MS); // NOSONAR(typescript:S2925) -- asserting the absence of auto-progress requires outwaiting its window +}; + +test.describe("Survey keyboard interaction @slow", () => { + // Seeded once and reused: the fixtures are per-test, so this is done lazily in + // beforeEach (same pattern as survey-accessibility.spec.ts). + let surveyUrl: string | undefined; + let pictureSurveyUrl: string | undefined; + + test.beforeEach(async ({ users, baseURL }) => { + if (surveyUrl) return; + const user = await users.create({ skipSurveySeed: true }); + if (!user.workspaceId) throw new Error("users.create() did not return a workspaceId"); + const surveyId = await seedKeyboardSurvey(user.workspaceId, user.id); + const pictureSurveyId = await seedPictureSurvey( + user.workspaceId, + user.id, + baseURL ?? "http://localhost:3000" + ); + surveyUrl = `/s/${surveyId}`; + pictureSurveyUrl = `/s/${pictureSurveyId}`; + }); + + test("arrows browse without selecting; Space selects and auto-progresses once", async ({ page }) => { + await page.goto(surveyUrl ?? ""); + await expect(page.getByText("Which plan are you on?")).toBeVisible(); + + // The first control of the card is focused on a link survey. + await expect.poll(async () => (await activeRadio(page)).isRadio, { timeout: 5000 }).toBe(true); + + // Browsing with arrows moves focus but selects nothing and never advances. + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await settleAutoProgressWindow(page); + const browsed = await activeRadio(page); + expect(browsed.checkedValue).toBeNull(); + await expect(page.getByText("Which plan are you on?")).toBeVisible(); + + // Space selects the focused option, auto-progress advances exactly one card, + // and focus lands on the new card's first control (an NPS radio). + await page.keyboard.press("Space"); + await expect(page.getByText("How likely are you to recommend us?")).toBeVisible(); + await expect.poll(async () => (await activeRadio(page)).isRadio, { timeout: 5000 }).toBe(true); + + // Same guarantees on the NPS scale, selecting with Enter this time. + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowRight"); + await settleAutoProgressWindow(page); + expect((await activeRadio(page)).checkedValue).toBeNull(); + await expect(page.getByText("How likely are you to recommend us?")).toBeVisible(); + + await page.keyboard.press("Enter"); + await expect(page.getByText("Which city do you work from?")).toBeVisible(); + }); + + const answerFirstTwoCards = async (page: Page): Promise => { + await page.goto(surveyUrl ?? ""); + await expect(page.getByText("Which plan are you on?")).toBeVisible(); + await page.getByText(SINGLE_CHOICES[0], { exact: true }).click(); + await expect(page.getByText("How likely are you to recommend us?")).toBeVisible(); + // The radio is sr-only; click its visible label cell. + await page.locator("label", { has: page.locator('input[aria-label="Rate 9 out of 10"]') }).click(); + await expect(page.getByText("Which city do you work from?")).toBeVisible(); + }; + + test("dropdown search is keyboard-escapable into the options in both variants", async ({ page }) => { + await answerFirstTwoCards(page); + + // Single-select dropdown: the trigger is the card's first control. + await expect + .poll(async () => page.evaluate(() => document.activeElement?.getAttribute("aria-haspopup"))) + .toBe("menu"); + await page.keyboard.press("Enter"); + const search = page.getByRole("textbox", { name: "Search..." }); + await expect(search).toBeFocused(); + + // ArrowDown leaves the search and highlights options; Enter selects, the + // dropdown commits on close and auto-progress advances. + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("menuitemradio", { name: DROPDOWN_CHOICES[1] })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.getByText("Which features do you use?")).toBeVisible(); + + // Multi-select dropdown: same path in, Space toggles and keeps the menu open, + // ArrowUp from the first option returns to the search input. + await page.keyboard.press("Enter"); + await expect(page.getByRole("textbox", { name: "Search..." })).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Space"); + await expect(page.getByRole("menuitemcheckbox", { name: MULTI_CHOICES[0] })).toHaveAttribute( + "data-state", + "checked" + ); + await page.keyboard.press("ArrowUp"); + await expect(page.getByRole("textbox", { name: "Search..." })).toBeFocused(); + await page.keyboard.press("Escape"); + + await navButton(page, "Next").click(); + await expect(page.getByText("Anything else to add?")).toBeVisible(); + }); + + test("failed submit focuses the first invalid control", async ({ page }) => { + await answerFirstTwoCards(page); + + // Answer the two dropdown cards with the pointer to reach the open-text card. + await page.getByRole("button", { name: /Which city do you work from/ }).click(); + await page.getByRole("menuitemradio", { name: DROPDOWN_CHOICES[0] }).click(); + await page.keyboard.press("Escape"); + await expect(page.getByText("Which features do you use?")).toBeVisible(); + await page.getByRole("button", { name: /Which features do you use/ }).click(); + await page.getByRole("menuitemcheckbox", { name: MULTI_CHOICES[0] }).click(); + await page.keyboard.press("Escape"); + await navButton(page, "Next").click(); + await expect(page.getByText("Anything else to add?")).toBeVisible(); + + // Empty required submit: stay on the card and focus the invalid input. + await navButton(page, "Finish").click(); + await expect(page.getByText("Anything else to add?")).toBeVisible(); + await expect + .poll(async () => + page.evaluate(() => { + const a = document.activeElement; + return a?.tagName === "INPUT" || a?.tagName === "TEXTAREA"; + }) + ) + .toBe(true); + + // Fixing the answer completes the survey. + await page.keyboard.type("All good!"); + await navButton(page, "Finish").click(); + await expect(page.getByText(ENDING_HEADLINE)).toBeVisible(); + }); + + test("picture selection: arrows browse without selecting; Space selects and auto-progresses", async ({ + page, + }) => { + await page.goto(pictureSurveyUrl ?? ""); + await expect(page.getByText("Pick the image you prefer")).toBeVisible(); + + // First picture radio is focused on a link survey. + await expect.poll(async () => (await activeRadio(page)).isRadio, { timeout: 5000 }).toBe(true); + + // Arrow browsing selects nothing and never advances. + await page.keyboard.press("ArrowRight"); + await settleAutoProgressWindow(page); + expect((await activeRadio(page)).checkedValue).toBeNull(); + await expect(page.getByText("Pick the image you prefer")).toBeVisible(); + + // Space selects the focused picture and auto-progress completes the survey. + await page.keyboard.press("Space"); + await expect(page.getByText(ENDING_HEADLINE)).toBeVisible(); + }); +}); diff --git a/packages/survey-ui/package.json b/packages/survey-ui/package.json index 260d89356e28..31279c342c1f 100644 --- a/packages/survey-ui/package.json +++ b/packages/survey-ui/package.json @@ -90,10 +90,12 @@ "@storybook/react-vite": "10.3.6", "@tailwindcss/postcss": "4.2.4", "@tailwindcss/vite": "4.2.4", + "@testing-library/react": "16.3.2", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "5.1.4", "@vitest/coverage-v8": "4.1.6", + "jsdom": "29.1.1", "react": "19.2.6", "react-dom": "19.2.6", "rimraf": "6.1.3", diff --git a/packages/survey-ui/src/components/elements/multi-select.tsx b/packages/survey-ui/src/components/elements/multi-select.tsx index e65ded9baa8f..b32c8ca4d56a 100644 --- a/packages/survey-ui/src/components/elements/multi-select.tsx +++ b/packages/survey-ui/src/components/elements/multi-select.tsx @@ -175,7 +175,6 @@ interface DropdownVariantProps { handleOptionAdd: (optionId: string) => void; handleOptionRemove: (optionId: string) => void; disabled: boolean; - headline: string; errorMessage?: string; displayText: string; hasOtherOption: boolean; @@ -199,7 +198,6 @@ function DropdownVariant({ handleOptionAdd, handleOptionRemove, disabled, - headline, errorMessage, displayText, hasOtherOption, @@ -239,6 +237,8 @@ function DropdownVariant({ hasNoResults, handleDropdownOpen, handleDropdownClose, + focusMenuItem, + handleContentKeyDown, } = useDropdownSearch({ options, hasOtherOption, otherOptionLabel, isSearchEnabled: showSearch }); return ( @@ -250,13 +250,20 @@ function DropdownVariant({ else handleDropdownClose(); }}> + {/* Named via aria-labelledby (headline + visible value) instead of aria-label: + a rich-text headline would leak raw HTML into the accessible name, and the + name must contain the visible text (WCAG 2.5.3 Label in Name). */} @@ -265,7 +272,8 @@ function DropdownVariant({ side={lockedSide} avoidCollisions={lockedSide === undefined} className="bg-option-bg border-input-border w-(--radix-dropdown-menu-trigger-width) overflow-hidden" - align="start"> + align="start" + onKeyDown={handleContentKeyDown}> {showSearch ? ( ) : null}
@@ -602,9 +611,11 @@ function MultiSelect({ ) : ( <> - {/* Dropdown trigger is a Radix menu button that names itself via aria-label={headline}; - the headline is a plain label here (no htmlFor) to avoid pointing at a non-input. */} + {/* Dropdown trigger is a Radix menu button named via aria-labelledby (headline id + + visible value id); the headline is a plain label here (no htmlFor) to avoid + pointing at a non-input. */} (e: React.KeyboardEvent) => { - if (disabled) return; - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleSelect(npsValue); - } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { - e.preventDefault(); - const direction = e.key === "ArrowLeft" ? -1 : 1; - const newValue = Math.max(0, Math.min(10, (currentValue ?? 0) + direction)); - handleSelect(newValue); - } - }; + // Keyboard interaction lives on the native radio inputs; selection is + // decoupled from arrow-key focus moves because selecting can trigger + // auto-progress (see useRovingRadioGroup). + const npsValues = Array.from({ length: 11 }, (_, i) => String(i)); + const { getRadioProps } = useRovingRadioGroup({ + values: npsValues, + selectedValue: currentValue === undefined ? undefined : String(currentValue), + onSelect: (v) => { + handleSelect(Number(v)); + }, + }); // Get NPS option color for color coding const getNPSOptionColor = (idx: number): string => { @@ -102,13 +101,11 @@ function NPS({ const { borderRadiusClasses, borderClasses } = getRTLScaleOptionClasses(isFirst, isLast); return ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive diff --git a/packages/survey-ui/src/components/elements/picture-select.tsx b/packages/survey-ui/src/components/elements/picture-select.tsx index 3853286cc7d1..6f726342b30d 100644 --- a/packages/survey-ui/src/components/elements/picture-select.tsx +++ b/packages/survey-ui/src/components/elements/picture-select.tsx @@ -1,9 +1,8 @@ -import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; import * as React from "react"; import { Checkbox } from "@/components/general/checkbox"; import { ElementError } from "@/components/general/element-error"; import { ElementHeader } from "@/components/general/element-header"; -import { RadioGroupItem } from "@/components/general/radio-group"; +import { useRovingRadioGroup } from "@/lib/use-roving-radio-group"; import { cn } from "@/lib/utils"; /** @@ -92,15 +91,24 @@ function PictureSelect({ onChange(newValue); }; + // Single select is a radio group whose selection can trigger auto-progress, + // so arrow-key focus moves must not select (see useRovingRadioGroup). + const { getRadioProps } = useRovingRadioGroup({ + values: options.map((option) => option.id), + selectedValue: allowMulti ? undefined : (selectedValues as string | undefined), + onSelect: handleSingleSelectChange, + }); + return (
- {/* Headline */} + {/* Headline: referenced by the option group via aria-labelledby (a htmlFor here + would dangle — no control carries the bare inputId). */} @@ -109,7 +117,11 @@ function PictureSelect({
{allowMulti ? ( -
+ // Native fieldset (group role) named by the headline; m-0/p-0/border-0/w-full + // reset the fieldset UA defaults so it lays out like a plain div. +
{options.map((option) => { const isSelected = (selectedValues as string[]).includes(option.id); const optionId = `${inputId}-${option.id}`; @@ -155,12 +167,16 @@ function PictureSelect({ ); })} -
+ ) : ( - {options.map((option) => { const optionId = `${inputId}-${option.id}`; @@ -174,6 +190,20 @@ function PictureSelect({ "rounded-option relative aspect-[162/97] w-full cursor-pointer transition-all", disabled && "cursor-not-allowed opacity-50" )}> + { + handleSingleSelectChange(option.id); + }} + {...getRadioProps(option.id)} + /> {/* Image container with border when selected */}
- {/* Selection indicator - Radio button for single select */} -
{ - e.stopPropagation(); - }}> -
+ ); })} -
+
)}
diff --git a/packages/survey-ui/src/components/elements/rating.tsx b/packages/survey-ui/src/components/elements/rating.tsx index 9ddc8369f7ca..b0fc22c689e8 100644 --- a/packages/survey-ui/src/components/elements/rating.tsx +++ b/packages/survey-ui/src/components/elements/rating.tsx @@ -15,6 +15,7 @@ import { TiredFace, WearyFace, } from "@/components/general/smileys"; +import { useRovingRadioGroup } from "@/lib/use-roving-radio-group"; import { cn, getRTLScaleOptionClasses } from "@/lib/utils"; /** @@ -183,9 +184,17 @@ function Rating({ } }; - // Keyboard navigation is handled natively: the options share a radio group - // name, so arrow keys move and select, and Space selects. No manual handler - // is needed, and the focusable control is the native , not the label. + // The options share a radio group name (single Tab stop, native as + // the focusable control), but selection is decoupled from arrow-key focus + // moves because selecting can trigger auto-progress (see useRovingRadioGroup). + const ratingValues = Array.from({ length: range }, (_, i) => String(i + 1)); + const { getRadioProps } = useRovingRadioGroup({ + values: ratingValues, + selectedValue: currentValue === undefined ? undefined : String(currentValue), + onSelect: (v) => { + handleSelect(Number(v)); + }, + }); // Get number option color for color coding const getRatingNumberOptionColor = (ratingRange: number, idx: number): string => { @@ -217,6 +226,7 @@ function Rating({ return ( @@ -309,6 +320,7 @@ function Rating({ disabled={disabled} className="sr-only" aria-label={`Rate ${String(number)} out of ${String(range)} stars`} + {...getRadioProps(String(number))} />
{isActive ? ( @@ -365,6 +377,7 @@ function Rating({ disabled={disabled} className="sr-only" aria-label={`Rate ${String(number)} out of ${String(range)}`} + {...getRadioProps(String(number))} />
diff --git a/packages/survey-ui/src/components/elements/single-select.tsx b/packages/survey-ui/src/components/elements/single-select.tsx index 78ef71130c28..3c0c4d05ba35 100644 --- a/packages/survey-ui/src/components/elements/single-select.tsx +++ b/packages/survey-ui/src/components/elements/single-select.tsx @@ -16,6 +16,7 @@ import { import { ElementError } from "@/components/general/element-error"; import { ElementHeader } from "@/components/general/element-header"; import { Input } from "@/components/general/input"; +import { useRovingRadioGroup } from "@/lib/use-roving-radio-group"; import { cn } from "@/lib/utils"; type Direction = "ltr" | "rtl" | "auto"; @@ -138,7 +139,6 @@ const useDropdownCommitState = ({ interface DropdownVariantProps { inputId: string; - headline: string; dir: Direction; disabled: boolean; errorMessage?: string; @@ -156,6 +156,8 @@ interface DropdownVariantProps { searchInputRef: React.RefObject; searchPlaceholder: string; searchNoResultsText: string; + focusMenuItem: (which: "first" | "last") => void; + handleContentKeyDown: (e: React.KeyboardEvent) => void; filteredRegularOptions: SingleSelectOption[]; otherMatchesSearch: boolean; otherOptionId?: string; @@ -173,7 +175,6 @@ interface DropdownVariantProps { function SingleSelectDropdownVariant({ inputId, - headline, dir, disabled, errorMessage, @@ -191,6 +192,8 @@ function SingleSelectDropdownVariant({ searchInputRef, searchPlaceholder, searchNoResultsText, + focusMenuItem, + handleContentKeyDown, filteredRegularOptions, otherMatchesSearch, otherOptionId, @@ -219,13 +222,20 @@ function SingleSelectDropdownVariant({ + {/* Named via aria-labelledby (headline + visible value) instead of aria-label: + a rich-text headline would leak raw HTML into the accessible name, and the + name must contain the visible text (WCAG 2.5.3 Label in Name). */} @@ -234,7 +244,8 @@ function SingleSelectDropdownVariant({ side={lockedSide} avoidCollisions={lockedSide === undefined} className="bg-option-bg border-input-border w-(--radix-dropdown-menu-trigger-width) overflow-hidden" - align="start"> + align="start" + onKeyDown={handleContentKeyDown}> {showSearch ? ( ) : null}
@@ -379,6 +391,8 @@ function RadioIndicator(): React.JSX.Element { ); } +type GetRadioProps = ReturnType["getRadioProps"]; + interface SingleSelectOptionItemProps { inputId: string; option: SingleSelectOption; @@ -388,6 +402,7 @@ interface SingleSelectOptionItemProps { dir: Direction; onSelect: (optionId: string) => void; onDeselect: (optionId: string) => void; + getRadioProps: GetRadioProps; } function SingleSelectOptionItem({ @@ -399,6 +414,7 @@ function SingleSelectOptionItem({ dir, onSelect, onDeselect, + getRadioProps, }: Readonly): React.JSX.Element { const optionId = `${inputId}-${option.id}`; @@ -426,6 +442,7 @@ function SingleSelectOptionItem({ onDeselect(option.id); } }} + {...getRadioProps(option.id)} /> {option.label} @@ -465,6 +482,18 @@ function SingleSelectListVariant({ } }; + // All radios of the group in DOM order: regular options, then "other", then "none". + const orderedValues = [ + ...regularOptions.map((option) => option.id), + ...(hasOtherOption && otherOptionId ? [otherOptionId] : []), + ...noneOptions.map((option) => option.id), + ]; + const { getRadioProps } = useRovingRadioGroup({ + values: orderedValues, + selectedValue, + onSelect: handleSelect, + }); + const renderOption = (option: SingleSelectOption): React.JSX.Element => ( ); @@ -500,6 +530,7 @@ function SingleSelectListVariant({ disabled={disabled} required={required} errorMessage={errorMessage} + getRadioProps={getRadioProps} /> ) : null} {noneOptions.map(renderOption)} @@ -523,6 +554,7 @@ interface OtherOptionLabelProps { disabled: boolean; required: boolean; errorMessage?: string; + getRadioProps: GetRadioProps; } function OtherOptionLabel({ @@ -540,6 +572,7 @@ function OtherOptionLabel({ disabled, required, errorMessage, + getRadioProps, }: Readonly): React.JSX.Element { const optionId = `${inputId}-${otherOptionId}`; const otherTextId = `${optionId}-input`; @@ -568,6 +601,7 @@ function OtherOptionLabel({ onDeselect(otherOptionId); } }} + {...getRadioProps(otherOptionId)} /> {otherOptionLabel} @@ -638,6 +672,8 @@ function SingleSelect({ hasNoResults, handleDropdownOpen, handleDropdownClose, + focusMenuItem, + handleContentKeyDown, } = useDropdownSearch({ options, hasOtherOption, otherOptionLabel, isSearchEnabled: showSearch }); const { @@ -719,9 +755,11 @@ function SingleSelect({ ) : ( <> - {/* Dropdown trigger is a Radix menu button that names itself via aria-label={headline}; - the headline is a plain label here (no htmlFor) to avoid pointing at a non-input. */} + {/* Dropdown trigger is a Radix menu button named via aria-labelledby (headline id + + visible value id); the headline is a plain label here (no htmlFor) to avoid + pointing at a non-input. */} ({ setLockedSide(undefined); }; + const getMenuItems = (): HTMLElement[] => { + // Resolve the menu from the search input itself: contentRef is not reliably + // attached under preact/compat (ref-as-prop forwarding), but the input always + // lives inside the open menu content. + const menu = searchInputRef.current?.closest("[role='menu']") ?? contentRef.current; + return menu + ? [ + ...menu.querySelectorAll( + "[role='menuitemradio']:not([data-disabled]),[role='menuitemcheckbox']:not([data-disabled]),[role='menuitem']:not([data-disabled])" + ), + ] + : []; + }; + + // Radix's menu keyboard handling ignores keydowns that originate from the + // search input, so arrow keys must move focus into the option list manually. + const focusMenuItem = (which: "first" | "last"): void => { + const items = getMenuItems(); + if (items.length === 0) return; + const target = which === "first" ? items[0] : items[items.length - 1]; + target.focus(); + }; + + // While focus is on the options, typing edits the search query instead of + // triggering the Radix menu typeahead; ArrowUp on the first option returns + // to the search input. Space/Enter keep their native select behavior. + const handleContentKeyDown = (e: React.KeyboardEvent): void => { + if (!isSearchEnabled) return; + const target = e.target as HTMLElement; + if (target === searchInputRef.current) return; + + const isPrintable = e.key.length === 1 && e.key !== " " && !e.ctrlKey && !e.metaKey && !e.altKey; + if (isPrintable || e.key === "Backspace") { + e.preventDefault(); + e.stopPropagation(); + setSearchQuery(isPrintable ? searchQuery + e.key : searchQuery.slice(0, -1)); + searchInputRef.current?.focus(); + return; + } + + if (e.key === "ArrowUp" && target === getMenuItems()[0]) { + e.preventDefault(); + e.stopPropagation(); + searchInputRef.current?.focus(); + } + }; + return { + focusMenuItem, + handleContentKeyDown, searchQuery, setSearchQuery, searchInputRef, @@ -100,6 +149,8 @@ interface DropdownSearchInputProps { searchInputRef: React.RefObject; placeholder: string; dir?: string; + /** Moves focus into the option list (ArrowDown → first, ArrowUp → last) */ + onNavigateToOptions: (which: "first" | "last") => void; } /** @@ -111,6 +162,7 @@ export function DropdownSearchInput({ searchInputRef, placeholder, dir, + onNavigateToOptions, }: Readonly): React.JSX.Element { return (
@@ -131,7 +183,11 @@ export function DropdownSearchInput({ e.stopPropagation(); setSearchQuery(""); } - } else if (e.key !== "ArrowDown" && e.key !== "ArrowUp") { + } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + onNavigateToOptions(e.key === "ArrowDown" ? "first" : "last"); + } else { e.stopPropagation(); } }} diff --git a/packages/survey-ui/src/lib/use-roving-radio-group.test.ts b/packages/survey-ui/src/lib/use-roving-radio-group.test.ts new file mode 100644 index 000000000000..76815da4f923 --- /dev/null +++ b/packages/survey-ui/src/lib/use-roving-radio-group.test.ts @@ -0,0 +1,210 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import type * as React from "react"; +import { describe, expect, test, vi } from "vitest"; +import { useRovingRadioGroup } from "./use-roving-radio-group"; + +const VALUES = ["a", "b", "c"]; + +/** + * Renders the hook and registers a real per value through + * the returned ref callbacks, so focus() and getComputedStyle() behave like in + * the real component. + */ +const setup = ({ + values = VALUES, + selectedValue, + rtl = false, +}: { + values?: string[]; + selectedValue?: string; + rtl?: boolean; +} = {}) => { + const onSelect = vi.fn(); + const view = renderHook( + (props: { selectedValue: string | undefined }) => + useRovingRadioGroup({ values, selectedValue: props.selectedValue, onSelect }), + { initialProps: { selectedValue } } + ); + + const inputs = new Map(); + const register = () => { + for (const value of values) { + let input = inputs.get(value); + if (!input) { + input = document.createElement("input"); + input.type = "radio"; + input.value = value; + if (rtl) input.style.direction = "rtl"; + document.body.appendChild(input); + inputs.set(value, input); + } + view.result.current.getRadioProps(value).ref(input); + } + }; + register(); + + const keyDown = (value: string, key: string) => { + const event = { + key, + currentTarget: inputs.get(value), + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent; + act(() => { + view.result.current.getRadioProps(value).onKeyDown(event); + }); + register(); // re-register after the state update, like a re-render would + return event; + }; + + const tabIndexes = () => values.map((value) => view.result.current.getRadioProps(value).tabIndex); + + return { view, inputs, keyDown, tabIndexes, onSelect }; +}; + +describe("useRovingRadioGroup", () => { + test("the first option is the single Tab stop when nothing is selected", () => { + const { tabIndexes } = setup(); + expect(tabIndexes()).toEqual([0, -1, -1]); + }); + + test("the selected option is the Tab stop", () => { + const { tabIndexes } = setup({ selectedValue: "b" }); + expect(tabIndexes()).toEqual([-1, 0, -1]); + }); + + test("falls back to the first option when the selected value is unknown", () => { + const { tabIndexes } = setup({ selectedValue: "nope" }); + expect(tabIndexes()).toEqual([0, -1, -1]); + }); + + test("ArrowRight/ArrowDown move focus forward without selecting, wrapping at the end", () => { + const { inputs, keyDown, tabIndexes, onSelect } = setup(); + + const event = keyDown("a", "ArrowRight"); + expect(event.preventDefault).toHaveBeenCalled(); + expect(document.activeElement).toBe(inputs.get("b")); + expect(tabIndexes()).toEqual([-1, 0, -1]); + + keyDown("b", "ArrowDown"); + expect(document.activeElement).toBe(inputs.get("c")); + + keyDown("c", "ArrowRight"); + expect(document.activeElement).toBe(inputs.get("a")); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test("ArrowLeft/ArrowUp move focus backwards, wrapping at the start", () => { + const { inputs, keyDown, onSelect } = setup(); + + keyDown("a", "ArrowLeft"); + expect(document.activeElement).toBe(inputs.get("c")); + + keyDown("c", "ArrowUp"); + expect(document.activeElement).toBe(inputs.get("b")); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test("horizontal arrows are inverted in RTL", () => { + const { inputs, keyDown } = setup({ rtl: true }); + + keyDown("a", "ArrowLeft"); // visually forward in RTL + expect(document.activeElement).toBe(inputs.get("b")); + + keyDown("b", "ArrowRight"); // visually backwards in RTL + expect(document.activeElement).toBe(inputs.get("a")); + + keyDown("a", "ArrowDown"); // vertical arrows are direction-agnostic + expect(document.activeElement).toBe(inputs.get("b")); + }); + + test("Home and End jump to the first and last option", () => { + const { inputs, keyDown } = setup({ selectedValue: "b" }); + + keyDown("b", "End"); + expect(document.activeElement).toBe(inputs.get("c")); + + keyDown("c", "Home"); + expect(document.activeElement).toBe(inputs.get("a")); + }); + + test("Enter selects the focused option and prevents implicit form submission", () => { + const { keyDown, onSelect } = setup(); + + const event = keyDown("b", "Enter"); + expect(event.preventDefault).toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalledWith("b"); + }); + + test("Space and other keys keep their native behavior", () => { + const { keyDown, onSelect } = setup(); + + const space = keyDown("a", " "); + const tab = keyDown("a", "Tab"); + expect(space.preventDefault).not.toHaveBeenCalled(); + expect(tab.preventDefault).not.toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test("ignores keys from values that are not part of the group", () => { + const { view, onSelect } = setup(); + + const event = { + key: "Enter", + currentTarget: document.createElement("input"), + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent; + act(() => { + view.result.current.getRadioProps("ghost").onKeyDown(event); + }); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + test("focusing an option makes it the roving Tab stop", () => { + const { view, tabIndexes } = setup(); + + act(() => { + view.result.current.getRadioProps("c").onFocus(); + }); + expect(tabIndexes()).toEqual([-1, -1, 0]); + }); + + test("leaving the group resets the Tab stop to the selected option", () => { + const { view, inputs, keyDown, tabIndexes } = setup({ selectedValue: "a" }); + + keyDown("a", "ArrowRight"); + expect(tabIndexes()).toEqual([-1, 0, -1]); + + // Blur towards an element outside the group. + act(() => { + view.result.current.getRadioProps("b").onBlur({ + relatedTarget: document.body, + } as unknown as React.FocusEvent); + }); + expect(tabIndexes()).toEqual([0, -1, -1]); + + // Blur towards another radio of the group keeps the roving position. + keyDown("a", "ArrowRight"); + act(() => { + view.result.current.getRadioProps("b").onBlur({ + relatedTarget: inputs.get("c"), + } as unknown as React.FocusEvent); + }); + expect(tabIndexes()).toEqual([-1, 0, -1]); + }); + + test("unregistering an input removes it from the focus targets", () => { + const { view, inputs, keyDown } = setup(); + + act(() => { + view.result.current.getRadioProps("b").ref(null); + }); + keyDown("a", "ArrowRight"); + // The ref is gone, so nothing was focused, but the Tab stop still moved. + expect(document.activeElement).not.toBe(inputs.get("b")); + expect(view.result.current.getRadioProps("b").tabIndex).toBe(0); + }); +}); diff --git a/packages/survey-ui/src/lib/use-roving-radio-group.ts b/packages/survey-ui/src/lib/use-roving-radio-group.ts new file mode 100644 index 000000000000..93faf142fb81 --- /dev/null +++ b/packages/survey-ui/src/lib/use-roving-radio-group.ts @@ -0,0 +1,114 @@ +import * as React from "react"; + +interface RovingRadioProps { + tabIndex: number; + ref: (el: HTMLInputElement | null) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onFocus: () => void; + onBlur: (e: React.FocusEvent) => void; +} + +/** + * Roving-tabindex keyboard model for a group of native radio inputs where + * selecting an option has side effects (e.g. survey auto-progress submits the + * card). Native radios select on arrow-key focus moves, which would make + * browsing the options impossible for keyboard users; per the WAI-ARIA APG + * radio-group guidance, selection is therefore decoupled from focus: + * + * - Tab enters the group at the selected option (or the first one). + * - Arrow keys move focus WITHOUT selecting (wrapping, RTL-aware). + * - Home/End jump to the first/last option. + * - Space (native) or Enter selects the focused option. + * + * Spread the returned props onto every radio `` of the group, in the + * same order as `values`. + */ +export function useRovingRadioGroup({ + values, + selectedValue, + onSelect, +}: { + /** Option values in visual/DOM order */ + values: string[]; + /** Currently selected option value, if any */ + selectedValue: string | undefined; + /** Called when the user explicitly selects the focused option via Enter */ + onSelect: (value: string) => void; +}): { + getRadioProps: (value: string) => RovingRadioProps; +} { + const inputRefs = React.useRef>(new Map()); + // The roving position while focus is inside the group; null = follow selection. + const [focusedValue, setFocusedValue] = React.useState(null); + + const rovingValue = + focusedValue ?? (selectedValue && values.includes(selectedValue) ? selectedValue : values[0]); + + const focusValue = (value: string): void => { + setFocusedValue(value); + inputRefs.current.get(value)?.focus(); + }; + + const handleKeyDown = (value: string) => (e: React.KeyboardEvent) => { + const index = values.indexOf(value); + if (index === -1) return; + + // dir="auto" is resolved per element, so read the effective direction from the DOM. + const isRtl = getComputedStyle(e.currentTarget).direction === "rtl"; + const nextKey = isRtl ? "ArrowLeft" : "ArrowRight"; + const prevKey = isRtl ? "ArrowRight" : "ArrowLeft"; + + switch (e.key) { + case nextKey: + case "ArrowDown": + e.preventDefault(); + focusValue(values[(index + 1) % values.length]); + break; + case prevKey: + case "ArrowUp": + e.preventDefault(); + focusValue(values[(index - 1 + values.length) % values.length]); + break; + case "Home": + e.preventDefault(); + focusValue(values[0]); + break; + case "End": + e.preventDefault(); + focusValue(values[values.length - 1]); + break; + case "Enter": + // Enter selects like Space; prevent implicit form submission so the + // selection itself (and any auto-progress) is the only effect. + e.preventDefault(); + onSelect(value); + break; + default: + // Space keeps its native behavior: the browser checks the focused radio + // and fires change, which is the explicit selection. + } + }; + + const handleBlur = (e: React.FocusEvent): void => { + // Reset the roving position when focus leaves the group so Tab re-enters + // at the selected option, matching native radio-group behavior. + const next = e.relatedTarget; + const leavingGroup = !next || ![...inputRefs.current.values()].includes(next as HTMLInputElement); + if (leavingGroup) setFocusedValue(null); + }; + + const getRadioProps = (value: string): RovingRadioProps => ({ + tabIndex: value === rovingValue ? 0 : -1, + ref: (el: HTMLInputElement | null) => { + if (el) inputRefs.current.set(value, el); + else inputRefs.current.delete(value); + }, + onKeyDown: handleKeyDown(value), + onFocus: () => { + setFocusedValue(value); + }, + onBlur: handleBlur, + }); + + return { getRadioProps }; +} diff --git a/packages/survey-ui/src/styles/globals.css b/packages/survey-ui/src/styles/globals.css index 3d80956390c1..87116b3e80cf 100644 --- a/packages/survey-ui/src/styles/globals.css +++ b/packages/survey-ui/src/styles/globals.css @@ -266,13 +266,38 @@ --fb-focus-ring-outer-width: 2px; } + /* Tailwind v4's transition-colors includes outline-color, and the focus rule + below sets a transparent outline (upgraded under forced-colors). Without a + transparent resting value, every focus would animate the outline from its + default (black) to transparent — a dark ring flashing outside the control. + Keep the resting outline-color transparent so that transition is a no-op. */ + #fbjs a, + #fbjs button, + #fbjs input, + #fbjs select, + #fbjs textarea, + #fbjs summary, + #fbjs [tabindex], + #fbjs label:has(input[type="radio"]), + #fbjs label:has(input[type="checkbox"]), + #fbjs [data-fb-focus-ring], + #fbjs [role="menuitem"], + #fbjs [role="menuitemradio"], + #fbjs [role="menuitemcheckbox"] { + outline-color: transparent; + } + #fbjs a:focus-visible, #fbjs button:focus-visible, #fbjs input:focus-visible, #fbjs select:focus-visible, #fbjs textarea:focus-visible, #fbjs summary:focus-visible, - #fbjs [tabindex]:focus-visible, + /* tabindex="-1" targets are script-focus-only (e.g. the ending card container focused + for screen-reader announcement) — not operable controls, so no indicator. :where() + keeps the exclusion at zero specificity so later same-specificity overrides (the + inset menu-item ring below) still win the cascade. */ + #fbjs [tabindex]:where(:not([tabindex="-1"])):focus-visible, /* A visually-hidden radio/checkbox drives a visible label: paint the ring on the label. */ #fbjs label:has(input[type="radio"]:focus-visible), #fbjs label:has(input[type="checkbox"]:focus-visible), @@ -305,6 +330,27 @@ outline: none !important; } + /* Scale cells (NPS / rating numbers) sit flush together and against the card's + scroll edge, so the default outer ring is clipped (WCAG 2.4.11). Paint a single + brand ring inset instead and raise the focused cell. The negative outline-offset + keeps it inset under forced-colors too, where box-shadows drop and the base + rule's positive-offset outline would otherwise reintroduce the clipping. */ + #fbjs label[data-fb-scale-cell]:has(input[type="radio"]:focus-visible) { + z-index: 20; + outline-offset: calc(-1 * var(--fb-focus-ring-outer-width)) !important; + box-shadow: inset 0 0 0 var(--fb-focus-ring-outer-width) var(--fb-focus-ring-outer-color) !important; + } + + /* Dropdown menu options: the menu content clips overflowing shadows (overflow-hidden + for its rounded corners), which cut the sides off the default outer ring. Paint the + ring inset for menu items as well (same forced-colors offset rationale as above). */ + #fbjs [role="menuitem"]:focus-visible, + #fbjs [role="menuitemradio"]:focus-visible, + #fbjs [role="menuitemcheckbox"]:focus-visible { + outline-offset: calc(-1 * var(--fb-focus-ring-outer-width)) !important; + box-shadow: inset 0 0 0 var(--fb-focus-ring-outer-width) var(--fb-focus-ring-outer-color) !important; + } + @media (forced-colors: active) { #fbjs a:focus-visible, #fbjs button:focus-visible, @@ -312,7 +358,7 @@ #fbjs select:focus-visible, #fbjs textarea:focus-visible, #fbjs summary:focus-visible, - #fbjs [tabindex]:focus-visible, + #fbjs [tabindex]:where(:not([tabindex="-1"])):focus-visible, #fbjs label:has(input[type="radio"]:focus-visible), #fbjs label:has(input[type="checkbox"]:focus-visible), #fbjs [data-fb-focus-ring]:has(:focus-visible), diff --git a/packages/surveys/src/components/general/block-conditional.tsx b/packages/surveys/src/components/general/block-conditional.tsx index 3e289485d73d..7dcc9cfa78f6 100644 --- a/packages/surveys/src/components/general/block-conditional.tsx +++ b/packages/surveys/src/components/general/block-conditional.tsx @@ -22,6 +22,29 @@ import { getFirstErrorMessage, validateBlockResponses } from "@/lib/validation/e const AUTO_PROGRESS_SUBMIT_DELAY_MS = 350; +const FOCUSABLE_CONTROL_SELECTOR = [ + 'input:not([type="hidden"]):not([tabindex="-1"]):not(:disabled)', + "textarea:not(:disabled)", + "select:not(:disabled)", + "button:not(:disabled)", + "a[href]", + '[tabindex="0"]', +].join(", "); + +/** + * Focuses the first interactive control inside `root`. With `preferInvalid`, + * controls flagged aria-invalid win, and scrolling is left to the caller. + */ +const focusFirstControl = (root: HTMLElement, preferInvalid = false): void => { + const invalidTarget = preferInvalid + ? root.querySelector( + ':is(input, textarea, select)[aria-invalid="true"]:not([tabindex="-1"]):not(:disabled)' + ) + : null; + const target = invalidTarget ?? root.querySelector(FOCUSABLE_CONTROL_SELECTOR); + target?.focus({ preventScroll: preferInvalid }); +}; + interface BlockConditionalProps { block: TSurveyBlock; value: TResponseData; @@ -38,6 +61,12 @@ interface BlockConditionalProps { setTtc: (ttc: TResponseTtc) => void; surveyId: string; autoFocusEnabled: boolean; + /** + * Move focus to the block's first interactive control when the card appears. + * True for user-initiated navigation (Next/Back/auto-progress) on any survey, + * and for the initial card when autofocus is allowed (not an embedded widget). + */ + shouldFocusOnMount: boolean; isBackButtonHidden: boolean; isAutoProgressingEnabled: boolean; onOpenExternalURL?: (url: string) => void | Promise; @@ -63,6 +92,7 @@ export function BlockConditional({ surveyId, onFileUpload, autoFocusEnabled, + shouldFocusOnMount, isBackButtonHidden, isAutoProgressingEnabled, onOpenExternalURL, @@ -83,6 +113,27 @@ export function BlockConditional({ // Ref to collect TTC values synchronously (state updates are async) const ttcCollectorRef = useRef({}); const autoProgressingInFlightRef = useRef(false); + const containerRef = useRef(null); + + // Screen-reader/keyboard users continue right where they act: when the card + // appears after user navigation (or on an autofocus-allowed initial render), + // focus its first control instead of dropping focus to the body, which made + // VoiceOver re-announce the whole survey dialog on every card change. + useEffect(() => { + if (!shouldFocusOnMount) return; + + // Defer so the card's content (and any card transition) has rendered. + const timeoutId = setTimeout(() => { + requestAnimationFrame(() => { + if (containerRef.current) focusFirstControl(containerRef.current); + }); + }, 0); + + return () => { + clearTimeout(timeoutId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once when the block mounts + }, []); const autoProgressElement = getAutoProgressElement(block.elements, isAutoProgressingEnabled); const shouldHideSubmitButton = shouldHideSubmitButtonForAutoProgress( block.elements, @@ -125,6 +176,11 @@ export function BlockConditional({ }) ) { autoProgressingInFlightRef.current = true; + // The selection is committed and the card is about to leave: drop focus now so + // the focus ring doesn't linger on the answered option during the submit delay + // (it read as a flashing ring). The next card focuses its first control on mount. + const active = document.activeElement; + if (active instanceof HTMLElement) active.blur(); // Defer submission so element-level change handlers can finalize TTC updates first. setTimeout(() => { try { @@ -324,12 +380,17 @@ export function BlockConditional({ if (hasValidationErrors) { setElementErrors(errorMap); - // Find the first element with an error and scroll to its input area (not the headline) + // Find the first element with an error, scroll to its input area (not the headline) + // and move focus to its first invalid control so keyboard users can fix it directly. const firstErrorElementId = Object.keys(errorMap)[0]; const form = elementFormRefs.current.get(firstErrorElementId); if (form) { const scrollTarget = form.querySelector("[data-element-input]") ?? form; scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" }); + // Defer so aria-invalid from the new error state is in the DOM. + requestAnimationFrame(() => { + focusFirstControl(form, true); + }); } return; } @@ -339,6 +400,9 @@ export function BlockConditional({ if (firstInvalidForm) { const scrollTarget = firstInvalidForm.querySelector("[data-element-input]") ?? firstInvalidForm; scrollTarget.scrollIntoView({ behavior: "smooth", block: "center" }); + requestAnimationFrame(() => { + focusFirstControl(firstInvalidForm, true); + }); return; } @@ -352,7 +416,7 @@ export function BlockConditional({ }; return ( -
+
{/* Scrollable container for the entire block */}
diff --git a/packages/surveys/src/components/general/ending-card.tsx b/packages/surveys/src/components/general/ending-card.tsx index 79a463445bbe..b340c1526441 100644 --- a/packages/surveys/src/components/general/ending-card.tsx +++ b/packages/surveys/src/components/general/ending-card.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "preact/hooks"; +import { useCallback, useEffect, useRef } from "preact/hooks"; import { useTranslation } from "react-i18next"; import { type TJsWorkspaceStateSurvey } from "@formbricks/types/js"; import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses"; @@ -46,6 +46,17 @@ export function EndingCard({ isOfflineWithPending = false, }: Readonly) { const { t } = useTranslation(); + const containerRef = useRef(null); + + // When the ending card has no button, nothing receives focus on arrival and + // screen readers are left announcing the surrounding dialog. Focus the card + // container instead so the thank-you message is read in place. + const hasButton = endingCard.type === "endScreen" && Boolean(endingCard.buttonLabel); + useEffect(() => { + if (isCurrent && autoFocusEnabled && isResponseSendingFinished && !hasButton) { + containerRef.current?.focus(); + } + }, [isCurrent, autoFocusEnabled, isResponseSendingFinished, hasButton]); const media = endingCard.type === "endScreen" && (endingCard.imageUrl ?? endingCard.videoUrl) ? ( @@ -134,7 +145,7 @@ export function EndingCard({ return ( -
+
{isResponseSendingFinished ? ( <> {endingCard.type === "endScreen" && ( diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index f69b7d62d4da..fa635acca352 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -253,6 +253,11 @@ export function Survey({ return localSurvey.blocks[0]?.id; }); + // True once the user navigated between cards (Next/Back/auto-progress). Moving focus into + // the new card is then a response to a user action (safe under WCAG 3.2.x), unlike the + // initial render of an embedded survey, where stealing focus from the host page is not. + const hasUserNavigatedRef = useRef(false); + const [errorType, setErrorType] = useState(undefined); const [showError, setShowError] = useState(false); const [isRetrying, setIsRetrying] = useState(false); @@ -933,8 +938,19 @@ export function Survey({ } }, [isResponseSendingFinished, isSurveyFinished, onFinished]); + // The outgoing card stays visible while the card transition cross-fades, so a + // control that kept focus would show its focus ring hanging mid-fade before + // vanishing with the card. Drop focus when navigation starts; the incoming + // card focuses its first control on mount. + const blurOutgoingCard = (): void => { + const active = document.activeElement; + if (active instanceof HTMLElement) active.blur(); + }; + const onSubmit = async (surveyResponseData: TResponseData, responsettc: TResponseTtc) => { isNavigatingBackRef.current = false; + hasUserNavigatedRef.current = true; + blurOutgoingCard(); // Get the first responded element ID for tracking const respondedElementIds = Object.keys(surveyResponseData); @@ -1033,6 +1049,8 @@ export function Survey({ const onBack = (): void => { isNavigatingBackRef.current = true; + hasUserNavigatedRef.current = true; + blurOutgoingCard(); let prevBlockId: string | undefined; // use history if available @@ -1133,7 +1151,7 @@ export function Survey({ survey={localSurvey} languageCode={selectedLanguage} responseCount={responseCount} - autoFocusEnabled={autoFocusEnabled} + autoFocusEnabled={autoFocusEnabled || hasUserNavigatedRef.current} isCurrent={offset === 0} responseData={responseData} variablesData={currentVariables} @@ -1152,7 +1170,7 @@ export function Survey({ survey={localSurvey} endingCard={endingCard} isRedirectDisabled={isRedirectDisabled} - autoFocusEnabled={autoFocusEnabled} + autoFocusEnabled={autoFocusEnabled || hasUserNavigatedRef.current} isCurrent={offset === 0} languageCode={selectedLanguage} isResponseSendingFinished={isResponseSendingFinished} @@ -1193,6 +1211,7 @@ export function Survey({ isLastBlock={block.id === localSurvey.blocks[localSurvey.blocks.length - 1].id} languageCode={selectedLanguage} autoFocusEnabled={autoFocusEnabled} + shouldFocusOnMount={autoFocusEnabled || hasUserNavigatedRef.current} isBackButtonHidden={localSurvey.isBackButtonHidden} isAutoProgressingEnabled={localSurvey.isAutoProgressingEnabled} onOpenExternalURL={onOpenExternalURL} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d6633a983f1..dade29e27133 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1031,6 +1031,9 @@ importers: '@tailwindcss/vite': specifier: 4.2.4 version: 4.2.4(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@8.20.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@types/react': specifier: 19.2.14 version: 19.2.14 @@ -1043,6 +1046,9 @@ importers: '@vitest/coverage-v8': specifier: 4.1.6 version: 4.1.6(vitest@4.1.6) + jsdom: + specifier: 29.1.1 + version: 29.1.1(@noble/hashes@2.0.1) react: specifier: 19.2.6 version: 19.2.6 @@ -9375,10 +9381,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} - engines: {node: 20 || >=22} - lru-cache@11.3.6: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} @@ -18461,7 +18463,7 @@ snapshots: '@testing-library/react@16.3.2(@testing-library/dom@8.20.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 '@testing-library/dom': 8.20.1 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -19172,7 +19174,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.0.16(@types/node@25.4.0)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) + vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@7.3.5(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/eslint-plugin@1.6.17(@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)(vitest@4.1.6)': dependencies: @@ -22401,8 +22403,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.6: {} - lru-cache@11.3.6: {} lru-cache@5.1.1: @@ -23149,7 +23149,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.2.6 + lru-cache: 11.3.6 minipass: 7.1.3 path-to-regexp@8.4.2: {} @@ -23674,7 +23674,7 @@ snapshots: react-error-boundary@6.0.0(react@19.2.6): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 react: 19.2.6 react-grid-layout@2.2.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6): @@ -24118,7 +24118,7 @@ snapshots: rtl-css-js@1.16.1: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.28.6 run-applescript@7.1.0: {}
- toggleAllOnPage(checked === true)} - /> - + toggleAllOnPage(checked === true)} + /> + {t("workspace.unify.collected_at")} {t("workspace.unify.source_type")} {t("workspace.unify.source_name")}
+

{t("workspace.unify.no_feedback_records")}

@@ -469,6 +472,7 @@ export const FeedbackRecordsTable = ({ contactId={record.user_id ? contactIdByUserId[record.user_id] : undefined} locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"} t={t} + canWrite={canWrite} isSelected={selectedIds.has(record.id)} onSelectChange={(checked) => toggleOne(record.id, checked)} onClick={() => openEditDrawer(record.id)} @@ -537,6 +541,7 @@ const FeedbackRecordRow = ({ contactId, locale, t, + canWrite, isSelected, onSelectChange, onClick, @@ -562,16 +567,18 @@ const FeedbackRecordRow = ({ onClick(); } }}> -
event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()}> - onSelectChange(checked === true)} - /> - event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()}> + onSelectChange(checked === true)} + /> + {collectedAt}
- {getTranslatedDimensionValueLabel(key, value, t) ?? formatCellValue(value)} + {renderCellValue(key, value)}