mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 19:38:37 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93ef8551e5 | |||
| fc58f16b95 | |||
| 5c0bc489b1 | |||
| 5f5860cb23 | |||
| 6412fe5a37 | |||
| 7dd1553fe2 | |||
| d28f4a9a00 | |||
| cac3b8e893 | |||
| 5fb7f54049 | |||
| 2eb5b3f4e6 | |||
| b2b4c58d7b | |||
| 6a4b0aad50 | |||
| 6609e1baca | |||
| 1d04df9d21 | |||
| 2cb4e27551 | |||
| 470a5fe6e1 | |||
| ef1b052042 |
@@ -1,14 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { authorizeTraefikRequest } from "@/modules/traefik-auth/service";
|
||||
|
||||
const handler = async (request: NextRequest): Promise<Response> => {
|
||||
return await authorizeTraefikRequest(request);
|
||||
};
|
||||
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
export const PUT = handler;
|
||||
export const PATCH = handler;
|
||||
export const DELETE = handler;
|
||||
export const HEAD = handler;
|
||||
export const OPTIONS = handler;
|
||||
+35
-13
@@ -3494,7 +3494,7 @@ checksums:
|
||||
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
|
||||
workspace/unify/auto_generated: 6e83e8febd63275692c444cb8074531d
|
||||
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
|
||||
workspace/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
|
||||
workspace/unify/clear_mapping: 9bd7c716667838b9f203f5af0ac2d651
|
||||
workspace/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
|
||||
workspace/unify/collected_at: b41902ddb4586ba4a4611d726b5014aa
|
||||
workspace/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
|
||||
@@ -3502,33 +3502,57 @@ checksums:
|
||||
workspace/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
|
||||
workspace/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
|
||||
workspace/unify/connector_duplicated_successfully: eb21ce42cdbef5fa38244206bf65fe4e
|
||||
workspace/unify/connector_name: 7be634ca4ecf3e4d196a89eb79087ebf
|
||||
workspace/unify/connector_name_hint: c4e2726c98fdb61eee89ea1e84193361
|
||||
workspace/unify/connector_status_updated_successfully: 443fd63b27f15a81ff146375adac739f
|
||||
workspace/unify/connector_updated_successfully: 11308c4a2881345209cefa06a3d90eab
|
||||
workspace/unify/connectors: 4d6f256254573013a8714c2afe98dcc2
|
||||
workspace/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60
|
||||
workspace/unify/created_by: 6775c2fa7d495fea48f1ad816daea93b
|
||||
workspace/unify/csv_advanced: 16e9b03eda6f6c1324cce54b96bbccd6
|
||||
workspace/unify/csv_advanced_hint: 83206f839ef9fc3ff40c095c7f29cde0
|
||||
workspace/unify/csv_at_least_one_row: 165bbc1853dde85c44eb5a587c52ce28
|
||||
workspace/unify/csv_auto_mapped: 086acc319b5493b8f0abadbcfdb00c0f
|
||||
workspace/unify/csv_auto_mapped_tooltip: 54248b2cd09cc960eecbc1f0720edb4a
|
||||
workspace/unify/csv_basic_required: e3419625edbe29b8528d564c90a5e70c
|
||||
workspace/unify/csv_basic_required_hint: dde8b6a78f64cf8d87a22886bbc1081f
|
||||
workspace/unify/csv_column_used_by: 80a98284fde45129f00c71f5dbb0601d
|
||||
workspace/unify/csv_columns: 280c5ba0b19ae5fa6d42f4d05a1771cb
|
||||
workspace/unify/csv_data_preview: 1b8670586f6ad3933c625711bad429d6
|
||||
workspace/unify/csv_empty_column_headers: 6e9af154be54778cfca32296fbd23ecb
|
||||
workspace/unify/csv_file_too_large: e94c7a7c26096aae9eddb2db30c5cfc1
|
||||
workspace/unify/csv_files_only: 920612b537521b14c154f1ac9843e947
|
||||
workspace/unify/csv_first_value: 16e8163a372aa766c45303a6853eb268
|
||||
workspace/unify/csv_fixed_value_action: d5f0e61e17cb06416ddc17ac2109e261
|
||||
workspace/unify/csv_fixed_value_label: fdadb8c4b9a99e245b1bb80e47ffef30
|
||||
workspace/unify/csv_import: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
workspace/unify/csv_import_complete: e8b6306e62e10c128f6464176ba879dd
|
||||
workspace/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27
|
||||
workspace/unify/csv_import_duplicate_warning: 9553a761431b53f7c2e75096d1f70947
|
||||
workspace/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
|
||||
workspace/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
|
||||
workspace/unify/csv_now_label: 1ae2b031b9443b56e1de70897677dbe6
|
||||
workspace/unify/csv_pick_column_placeholder: e90ae509ccdedf368ff8054ae274a9bf
|
||||
workspace/unify/csv_required_fields_missing: 7df71aeb1d6136b31221e6f5eadd8a7e
|
||||
workspace/unify/csv_response_preview: 4b89c7162f8274fb06298ce3f9e5fcfc
|
||||
workspace/unify/csv_rows_count: 5ef81805a6f576c41018938e256624da
|
||||
workspace/unify/csv_sample_label: 92f4b17f95982b5f264c7f5a0bec74b1
|
||||
workspace/unify/csv_saved_mapping_missing_columns: df0d3fb7eff43bc21fe5fd5ac0ea8842
|
||||
workspace/unify/csv_source_context: 96e2f41e15b8272319817646e2fe8738
|
||||
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/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
|
||||
workspace/unify/delete_feedback_record: 86d7262c000cfb1f91ea373036cd3616
|
||||
workspace/unify/delete_feedback_record_confirmation: dd2b12e75cb52a73f92c997c347a8f36
|
||||
workspace/unify/delete_feedback_records_confirmation: cd8a4ba828963fb6dab5c96606565079
|
||||
workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941
|
||||
workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e
|
||||
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
|
||||
workspace/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
|
||||
workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9
|
||||
workspace/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0
|
||||
workspace/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
|
||||
workspace/unify/enter_value: 4f068bb59617975c1e546218373122cd
|
||||
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
|
||||
workspace/unify/error_connector_field_mapping_duplicate: 4e507ae4a99f53dfa75d849d32566bf2
|
||||
workspace/unify/error_connector_formbricks_mapping_duplicate: 48524e22fa33bd0b2829f7aea49c711b
|
||||
@@ -3536,17 +3560,19 @@ checksums:
|
||||
workspace/unify/error_connector_name_required: b2d5f79f6126e23128d7bef0c1736ff2
|
||||
workspace/unify/error_connector_questions_required: d7d8e388959ab83a9195ba63bebf6516
|
||||
workspace/unify/error_connector_survey_required: 1f49086dfb874307aae1136e88c3d514
|
||||
workspace/unify/failed_to_delete_feedback_records: 6096404d164fda196734675885e278c3
|
||||
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
|
||||
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
|
||||
workspace/unify/feedback_directory: 156fa9957e1ee5eadf1f44226a2365e4
|
||||
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_fields: 88c0f13afeb88fe751f85e79b0f73064
|
||||
workspace/unify/feedback_record_mcp: cdddbef2944489820fd7f376a49c2803
|
||||
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
|
||||
workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
|
||||
workspace/unify/feedback_sources: e58ec9be19db8789e7096a756d24f2b2
|
||||
workspace/unify/feedback_sources_directory_access_multiple: 11d613bc1e9825aa6faa3db17ae678eb
|
||||
@@ -3565,7 +3591,7 @@ checksums:
|
||||
workspace/unify/import_historical_responses: d7941f65344b6bfba56a40cc53a063b4
|
||||
workspace/unify/import_historical_responses_description: c860f7c6dbe8b74383ecf9cae9c219a0
|
||||
workspace/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
|
||||
workspace/unify/import_via_source_name: eae32ae2fc87f925ca016fe8283bcbfd
|
||||
workspace/unify/import_via_source_name: 9fa1fd9cec152ec36e267aafcbcad2c1
|
||||
workspace/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
|
||||
workspace/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
|
||||
workspace/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
|
||||
@@ -3584,15 +3610,12 @@ checksums:
|
||||
workspace/unify/no_feedback_directory_linked_title: b01a53d508aeeb1c8ad080ee07efcd04
|
||||
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
|
||||
workspace/unify/no_formbricks_surveys_available_description: 2218334806910168bdfb6f283f31b963
|
||||
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
|
||||
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
|
||||
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
workspace/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
|
||||
workspace/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
|
||||
workspace/unify/refresh_feedback_records: c111751e02a7dee57390ed7fb79cfcc6
|
||||
workspace/unify/refreshing_feedback_records: 2a03b44510ebe19eea6473639e9a7222
|
||||
workspace/unify/request_feedback_source: 51045caa2c81dee971d23a1841d19a7e
|
||||
workspace/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
workspace/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
workspace/unify/search_feedback: db1e8dd05944bb928b96e3822aee3379
|
||||
workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
|
||||
@@ -3621,12 +3644,11 @@ checksums:
|
||||
workspace/unify/set_value: b8a86f8da957ebd599ece4b1b1936a78
|
||||
workspace/unify/setup_connection: cce7d9c488d737d04e70bed929a46f8a
|
||||
workspace/unify/showing_count_loaded: f443aae08223b65fbd5521d6e69534a4
|
||||
workspace/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
|
||||
workspace/unify/showing_rows: e851514ed6c4d2e453d0098af2d773bd
|
||||
workspace/unify/source: 45309626f464f4bda161ee783a4c8c80
|
||||
workspace/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
|
||||
workspace/unify/source_connect_feedback_record_mcp_description: a3f56e2a6e403f4021e83f1b1a466d95
|
||||
workspace/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
|
||||
workspace/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
|
||||
workspace/unify/source_id: 134a9a7d473508c5623ac724a5ba4be9
|
||||
workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61
|
||||
workspace/unify/source_type: d1ff69af76c687eb189db72030717570
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
updateConnector,
|
||||
updateConnectorWithMappings,
|
||||
} from "./service";
|
||||
import {
|
||||
formatMissingRequiredCsvFieldMappingsMessage,
|
||||
getMissingRequiredCsvFieldMappings,
|
||||
sanitizeCsvFieldMappings,
|
||||
} from "./utils";
|
||||
|
||||
const ZDeleteConnectorAction = z.object({
|
||||
connectorId: ZId,
|
||||
@@ -125,6 +130,19 @@ const ZFormbricksSurveyMapping = z.object({
|
||||
elementIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
const sanitizeAndValidateCsvFieldMappings = (
|
||||
fieldMappings: z.infer<typeof ZConnectorFieldMappingCreateInput>[]
|
||||
) => {
|
||||
const sanitized = sanitizeCsvFieldMappings(fieldMappings) ?? [];
|
||||
const missing = getMissingRequiredCsvFieldMappings(sanitized);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new InvalidInputError(formatMissingRequiredCsvFieldMappingsMessage());
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const ZCreateConnectorWithMappingsAction = z
|
||||
.object({
|
||||
workspaceId: ZId,
|
||||
@@ -197,7 +215,13 @@ export const createConnectorWithMappingsAction = authenticatedActionClient
|
||||
|
||||
mappingsInput = await resolveFormbricksMappingsInput(formbricksMappings);
|
||||
} else if (fieldMappings?.length) {
|
||||
mappingsInput = { type: "field", mappings: fieldMappings };
|
||||
mappingsInput = {
|
||||
type: "field",
|
||||
mappings:
|
||||
parsedInput.connectorInput.type === "csv"
|
||||
? sanitizeAndValidateCsvFieldMappings(fieldMappings)
|
||||
: fieldMappings,
|
||||
};
|
||||
}
|
||||
|
||||
return createConnectorWithMappings(
|
||||
@@ -256,7 +280,21 @@ export const updateConnectorWithMappingsAction = authenticatedActionClient
|
||||
|
||||
mappingsInput = await resolveFormbricksMappingsInput(parsedInput.formbricksMappings);
|
||||
} else if (parsedInput.fieldMappings && parsedInput.fieldMappings.length > 0) {
|
||||
mappingsInput = { type: "field", mappings: parsedInput.fieldMappings };
|
||||
const connector = await prisma.connector.findUnique({
|
||||
where: { id: parsedInput.connectorId, workspaceId: parsedInput.workspaceId },
|
||||
select: { type: true },
|
||||
});
|
||||
if (!connector) {
|
||||
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
|
||||
}
|
||||
|
||||
mappingsInput = {
|
||||
type: "field",
|
||||
mappings:
|
||||
connector.type === "csv"
|
||||
? sanitizeAndValidateCsvFieldMappings(parsedInput.fieldMappings)
|
||||
: parsedInput.fieldMappings,
|
||||
};
|
||||
}
|
||||
|
||||
return updateConnectorWithMappings(
|
||||
@@ -318,13 +356,14 @@ export const duplicateConnectorAction = authenticatedActionClient
|
||||
})),
|
||||
};
|
||||
} else if (source.fieldMappings.length > 0) {
|
||||
const projected = source.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId,
|
||||
targetFieldId: m.targetFieldId,
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
}));
|
||||
mappingsInput = {
|
||||
type: "field",
|
||||
mappings: source.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId,
|
||||
targetFieldId: m.targetFieldId,
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
})),
|
||||
mappings: source.type === "csv" ? sanitizeAndValidateCsvFieldMappings(projected) : projected,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import type { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { CSV_IMPORT_MISSING_COLUMNS_ERROR_CODE } from "@/modules/ee/unify-feedback/sources/types";
|
||||
import { importCsvData } from "./csv-import";
|
||||
|
||||
vi.mock("@/modules/hub", () => ({
|
||||
@@ -16,6 +17,13 @@ const { transformCsvRowsToFeedbackRecords } = vi.mocked(await import("./csv-tran
|
||||
|
||||
const NOW = new Date("2026-02-25T10:00:00.000Z");
|
||||
|
||||
const matchingCsvRow = {
|
||||
response_id: "resp-1",
|
||||
question_id: "q1",
|
||||
question: "Question?",
|
||||
feedback: "Great",
|
||||
};
|
||||
|
||||
const makeConnector = (overrides?: Partial<TConnectorWithMappings>): TConnectorWithMappings => ({
|
||||
id: "conn-1",
|
||||
createdAt: NOW,
|
||||
@@ -24,6 +32,7 @@ const makeConnector = (overrides?: Partial<TConnectorWithMappings>): TConnectorW
|
||||
type: "csv",
|
||||
status: "active",
|
||||
workspaceId: "env-1",
|
||||
feedbackDirectoryId: "tenant-test",
|
||||
lastSyncAt: null,
|
||||
createdBy: null,
|
||||
creatorName: null,
|
||||
@@ -34,8 +43,8 @@ const makeConnector = (overrides?: Partial<TConnectorWithMappings>): TConnectorW
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId: "feedback",
|
||||
targetFieldId: "value_text",
|
||||
sourceFieldId: "response_id",
|
||||
targetFieldId: "submission_id",
|
||||
staticValue: null,
|
||||
},
|
||||
{
|
||||
@@ -43,6 +52,42 @@ const makeConnector = (overrides?: Partial<TConnectorWithMappings>): TConnectorW
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId: "question_id",
|
||||
targetFieldId: "field_id",
|
||||
staticValue: null,
|
||||
},
|
||||
{
|
||||
id: "fm-3",
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId: "question",
|
||||
targetFieldId: "field_label",
|
||||
staticValue: null,
|
||||
},
|
||||
{
|
||||
id: "fm-4",
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId: "",
|
||||
targetFieldId: "field_type",
|
||||
staticValue: "text",
|
||||
},
|
||||
{
|
||||
id: "fm-5",
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId: "feedback",
|
||||
targetFieldId: "response_value",
|
||||
staticValue: null,
|
||||
},
|
||||
{
|
||||
id: "fm-6",
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
workspaceId: "env-1",
|
||||
sourceFieldId: "",
|
||||
targetFieldId: "source_type",
|
||||
staticValue: "csv",
|
||||
@@ -66,10 +111,40 @@ describe("importCsvData", () => {
|
||||
await expect(importCsvData(connector, [{ feedback: "test" }])).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when submission_id is not mapped", async () => {
|
||||
const connector = makeConnector({
|
||||
fieldMappings: makeConnector().fieldMappings.filter(
|
||||
(mapping) => mapping.targetFieldId !== "submission_id"
|
||||
),
|
||||
});
|
||||
|
||||
await expect(importCsvData(connector, [matchingCsvRow])).rejects.toThrow(
|
||||
"This saved CSV mapping is incomplete"
|
||||
);
|
||||
expect(transformCsvRowsToFeedbackRecords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when uploaded CSV is missing a mapped source column", async () => {
|
||||
const connector = makeConnector({
|
||||
fieldMappings: makeConnector().fieldMappings.map((mapping) =>
|
||||
mapping.targetFieldId === "submission_id" ? { ...mapping, sourceFieldId: "source_id" } : mapping
|
||||
),
|
||||
});
|
||||
|
||||
await expect(importCsvData(connector, [matchingCsvRow])).rejects.toThrow(
|
||||
CSV_IMPORT_MISSING_COLUMNS_ERROR_CODE
|
||||
);
|
||||
expect(transformCsvRowsToFeedbackRecords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns zeros when all rows are skipped", async () => {
|
||||
transformCsvRowsToFeedbackRecords.mockReturnValue({ records: [], skipped: 3 });
|
||||
|
||||
const result = await importCsvData(makeConnector(), [{ a: "1" }, { a: "2" }, { a: "3" }]);
|
||||
const result = await importCsvData(makeConnector(), [
|
||||
matchingCsvRow,
|
||||
{ ...matchingCsvRow, response_id: "resp-2" },
|
||||
{ ...matchingCsvRow, response_id: "resp-3" },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ successes: 0, failures: 0, skipped: 3 });
|
||||
expect(createFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
@@ -78,8 +153,22 @@ describe("importCsvData", () => {
|
||||
test("sends transformed records to Hub and counts results", async () => {
|
||||
transformCsvRowsToFeedbackRecords.mockReturnValue({
|
||||
records: [
|
||||
{ source_type: "csv", field_id: "q1", field_type: "text" as const, value_text: "Good" },
|
||||
{ source_type: "csv", field_id: "q2", field_type: "text" as const, value_text: "Bad" },
|
||||
{
|
||||
source_type: "csv",
|
||||
tenant_id: "tenant-test",
|
||||
submission_id: "resp-1",
|
||||
field_id: "q1",
|
||||
field_type: "text" as const,
|
||||
value_text: "Good",
|
||||
},
|
||||
{
|
||||
source_type: "csv",
|
||||
tenant_id: "tenant-test",
|
||||
submission_id: "resp-2",
|
||||
field_id: "q2",
|
||||
field_type: "text" as const,
|
||||
value_text: "Bad",
|
||||
},
|
||||
],
|
||||
skipped: 1,
|
||||
});
|
||||
@@ -91,7 +180,11 @@ describe("importCsvData", () => {
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await importCsvData(makeConnector(), [{}, {}, {}]);
|
||||
const result = await importCsvData(makeConnector(), [
|
||||
matchingCsvRow,
|
||||
{ ...matchingCsvRow, response_id: "resp-2" },
|
||||
{ ...matchingCsvRow, response_id: "resp-3" },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ successes: 1, failures: 1, skipped: 1 });
|
||||
});
|
||||
@@ -99,6 +192,8 @@ describe("importCsvData", () => {
|
||||
test("processes records in batches of 50", async () => {
|
||||
const records = Array.from({ length: 120 }, (_, i) => ({
|
||||
source_type: "csv",
|
||||
tenant_id: "tenant-test",
|
||||
submission_id: `resp-${i}`,
|
||||
field_id: `q${i}`,
|
||||
field_type: "text" as const,
|
||||
value_text: `row ${i}`,
|
||||
@@ -111,7 +206,7 @@ describe("importCsvData", () => {
|
||||
|
||||
await importCsvData(
|
||||
makeConnector(),
|
||||
Array.from({ length: 120 }, () => ({}))
|
||||
Array.from({ length: 120 }, (_, i) => ({ ...matchingCsvRow, response_id: `resp-${i}` }))
|
||||
);
|
||||
|
||||
expect(createFeedbackRecordsBatch).toHaveBeenCalledTimes(3);
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import "server-only";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { CSV_IMPORT_MISSING_COLUMNS_ERROR_CODE } from "@/modules/ee/unify-feedback/sources/types";
|
||||
import { createFeedbackRecordsBatch } from "@/modules/hub";
|
||||
import { transformCsvRowsToFeedbackRecords } from "./csv-transform";
|
||||
import { TImportResult } from "./import";
|
||||
import {
|
||||
formatMissingRequiredCsvFieldMappingsMessage,
|
||||
getMissingCsvMappedSourceColumns,
|
||||
getMissingRequiredCsvFieldMappings,
|
||||
} from "./utils";
|
||||
|
||||
const CSV_BATCH_SIZE = 50;
|
||||
|
||||
@@ -19,6 +25,19 @@ export const importCsvData = async (
|
||||
throw new InvalidInputError("Connector has no field mappings configured");
|
||||
}
|
||||
|
||||
const missingMappedColumns = getMissingCsvMappedSourceColumns(
|
||||
connector.fieldMappings,
|
||||
Object.keys(csvRows[0] ?? {})
|
||||
);
|
||||
if (missingMappedColumns.length > 0) {
|
||||
throw new InvalidInputError(CSV_IMPORT_MISSING_COLUMNS_ERROR_CODE);
|
||||
}
|
||||
|
||||
const missing = getMissingRequiredCsvFieldMappings(connector.fieldMappings);
|
||||
if (missing.length > 0) {
|
||||
throw new InvalidInputError(formatMissingRequiredCsvFieldMappingsMessage());
|
||||
}
|
||||
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords(
|
||||
csvRows,
|
||||
connector.fieldMappings,
|
||||
|
||||
@@ -22,6 +22,7 @@ const makeMapping = (
|
||||
const baseMappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("feedback_text", "value_text"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("response_id", "submission_id"),
|
||||
makeMapping("", "source_type", "survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
@@ -32,14 +33,16 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
const row = {
|
||||
feedback_text: "Great product!",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.source_type).toBe("survey");
|
||||
expect(result!.source_type).toBe("csv");
|
||||
expect(result!.field_id).toBe("q1");
|
||||
expect(result!.submission_id).toBe("resp-1");
|
||||
expect(result!.field_type).toBe("text");
|
||||
expect(result!.value_text).toBe("Great product!");
|
||||
expect(result!.collected_at).toBe("2026-01-15T10:00:00.000Z");
|
||||
@@ -58,6 +61,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
const row = {
|
||||
feedback_text: "Great product!",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
@@ -65,18 +69,17 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("auto-generates submission_id as a UUID when unmapped", () => {
|
||||
test("returns null when submission_id is unmapped", () => {
|
||||
const row = {
|
||||
feedback_text: "Great product!",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const a = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
|
||||
const b = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
|
||||
const mappings = baseMappings.filter((m) => m.targetFieldId !== "submission_id");
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
|
||||
expect(a!.submission_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
expect(b!.submission_id).not.toBe(a!.submission_id);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("uses explicit submission_id mapping when provided", () => {
|
||||
@@ -122,6 +125,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
const row = {
|
||||
feedback_text: "Good",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
rating: "4.5",
|
||||
};
|
||||
@@ -135,6 +139,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
const row = {
|
||||
feedback_text: "Good",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
rating: "not-a-number",
|
||||
};
|
||||
@@ -148,7 +153,13 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "true" },
|
||||
{
|
||||
feedback_text: "x",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-01-15",
|
||||
is_promoter: "true",
|
||||
},
|
||||
mappings,
|
||||
TENANT
|
||||
)!.value_boolean
|
||||
@@ -156,7 +167,13 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "0" },
|
||||
{
|
||||
feedback_text: "x",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-01-15",
|
||||
is_promoter: "0",
|
||||
},
|
||||
mappings,
|
||||
TENANT
|
||||
)!.value_boolean
|
||||
@@ -164,7 +181,13 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "yes" },
|
||||
{
|
||||
feedback_text: "x",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-01-15",
|
||||
is_promoter: "yes",
|
||||
},
|
||||
mappings,
|
||||
TENANT
|
||||
)!.value_boolean
|
||||
@@ -177,34 +200,41 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("response_id", "submission_id"),
|
||||
makeMapping("", "source_type", "csv"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("", "collected_at", "$now"),
|
||||
];
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord({ question: "q1" }, mappings, TENANT);
|
||||
const result = transformCsvRowToFeedbackRecord(
|
||||
{ question: "q1", response_id: "resp-1" },
|
||||
mappings,
|
||||
TENANT
|
||||
);
|
||||
expect(result!.collected_at).toBe(NOW.toISOString());
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("uses static value over source field", () => {
|
||||
test("ignores source_type mappings and uses csv", () => {
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("response_id", "submission_id"),
|
||||
makeMapping("type_column", "source_type", "always_survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
const row = { question: "q1", type_column: "review", timestamp: "2026-01-15" };
|
||||
const row = { question: "q1", response_id: "resp-1", type_column: "review", timestamp: "2026-01-15" };
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
expect(result!.source_type).toBe("always_survey");
|
||||
expect(result!.source_type).toBe("csv");
|
||||
});
|
||||
|
||||
test("skips empty string values", () => {
|
||||
const row = {
|
||||
feedback_text: "",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
@@ -217,6 +247,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-01-15",
|
||||
meta: '{"device":"mobile","version":"2.1"}',
|
||||
};
|
||||
@@ -230,6 +261,7 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-01-15",
|
||||
meta: "just a string",
|
||||
};
|
||||
@@ -242,25 +274,51 @@ describe("transformCsvRowToFeedbackRecord", () => {
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "not-a-date",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
|
||||
expect(result!.collected_at).toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects parseable non-ISO timestamp strings", () => {
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "01/15/2026",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
|
||||
expect(result!.collected_at).toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects ISO-shaped timestamp strings with invalid dates", () => {
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-02-31",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings, TENANT);
|
||||
expect(result!.collected_at).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformCsvRowsToFeedbackRecords", () => {
|
||||
test("transforms multiple rows and counts skipped", () => {
|
||||
const rows = [
|
||||
{ feedback_text: "Good", question: "q1", timestamp: "2026-01-15" },
|
||||
{ feedback_text: "Bad", question: "q2", timestamp: "2026-01-16" },
|
||||
const rows: Record<string, string>[] = [
|
||||
{ feedback_text: "Good", question: "q1", response_id: "resp-1", timestamp: "2026-01-15" },
|
||||
{ feedback_text: "Bad", question: "q2", response_id: "resp-2", timestamp: "2026-01-16" },
|
||||
{ feedback_text: "No question field" },
|
||||
];
|
||||
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("feedback_text", "value_text"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("response_id", "submission_id"),
|
||||
makeMapping("", "source_type", "survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
@@ -272,9 +330,8 @@ describe("transformCsvRowsToFeedbackRecords", () => {
|
||||
expect(skipped).toBe(1);
|
||||
expect(records[0].field_id).toBe("q1");
|
||||
expect(records[1].field_id).toBe("q2");
|
||||
expect(records[0].submission_id).toBeTruthy();
|
||||
expect(records[1].submission_id).toBeTruthy();
|
||||
expect(records[0].submission_id).not.toBe(records[1].submission_id);
|
||||
expect(records[0].submission_id).toBe("resp-1");
|
||||
expect(records[1].submission_id).toBe("resp-2");
|
||||
});
|
||||
|
||||
test("returns empty records for empty input", () => {
|
||||
@@ -283,3 +340,149 @@ describe("transformCsvRowsToFeedbackRecords", () => {
|
||||
expect(skipped).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("response_value routing", () => {
|
||||
const responseMappings = (fieldType: string): TConnectorFieldMapping[] => [
|
||||
makeMapping("answer", "response_value"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("response_id", "submission_id"),
|
||||
makeMapping("", "source_type", "csv"),
|
||||
makeMapping("", "field_type", fieldType),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
test("text routes to value_text", () => {
|
||||
const result = transformCsvRowToFeedbackRecord(
|
||||
{ answer: "great service", question: "q1", response_id: "resp-1", timestamp: "2026-01-15" },
|
||||
responseMappings("text"),
|
||||
TENANT
|
||||
);
|
||||
expect(result!.value_text).toBe("great service");
|
||||
expect(result!.value_number).toBeUndefined();
|
||||
});
|
||||
|
||||
test("categorical routes to value_text", () => {
|
||||
const result = transformCsvRowToFeedbackRecord(
|
||||
{ answer: "option_a", question: "q1", response_id: "resp-1", timestamp: "2026-01-15" },
|
||||
responseMappings("categorical"),
|
||||
TENANT
|
||||
);
|
||||
expect(result!.value_text).toBe("option_a");
|
||||
});
|
||||
|
||||
test.each(["number", "nps", "csat", "ces", "rating"])("%s routes to value_number", (fieldType) => {
|
||||
const result = transformCsvRowToFeedbackRecord(
|
||||
{ answer: "9", question: "q1", response_id: "resp-1", timestamp: "2026-01-15" },
|
||||
responseMappings(fieldType),
|
||||
TENANT
|
||||
);
|
||||
expect(result!.value_number).toBe(9);
|
||||
expect(result!.value_text).toBeUndefined();
|
||||
});
|
||||
|
||||
test("boolean routes to value_boolean", () => {
|
||||
const result = transformCsvRowToFeedbackRecord(
|
||||
{ answer: "true", question: "q1", response_id: "resp-1", timestamp: "2026-01-15" },
|
||||
responseMappings("boolean"),
|
||||
TENANT
|
||||
);
|
||||
expect(result!.value_boolean).toBe(true);
|
||||
});
|
||||
|
||||
test("date routes to value_date", () => {
|
||||
const result = transformCsvRowToFeedbackRecord(
|
||||
{ answer: "2026-03-01", question: "q1", response_id: "resp-1", timestamp: "2026-01-15" },
|
||||
responseMappings("date"),
|
||||
TENANT
|
||||
);
|
||||
expect(result!.value_date).toBe("2026-03-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("date response rejects parseable non-ISO strings", () => {
|
||||
const result = transformCsvRowToFeedbackRecord(
|
||||
{ answer: "March 1, 2026", question: "q1", response_id: "resp-1", timestamp: "2026-01-15" },
|
||||
responseMappings("date"),
|
||||
TENANT
|
||||
);
|
||||
expect(result!.value_date).toBeUndefined();
|
||||
});
|
||||
|
||||
test("invalid field_type causes the row to be skipped", () => {
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("answer", "response_value"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("response_id", "submission_id"),
|
||||
makeMapping("", "source_type", "csv"),
|
||||
makeMapping("", "field_type", "not_a_real_enum"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
const result = transformCsvRowToFeedbackRecord(
|
||||
{ answer: "x", question: "q1", response_id: "resp-1", timestamp: "2026-01-15" },
|
||||
mappings,
|
||||
TENANT
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("missing field_type causes the row to be skipped", () => {
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("answer", "response_value"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("response_id", "submission_id"),
|
||||
makeMapping("", "source_type", "csv"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
const result = transformCsvRowToFeedbackRecord(
|
||||
{ answer: "x", question: "q1", response_id: "resp-1", timestamp: "2026-01-15" },
|
||||
mappings,
|
||||
TENANT
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenant_id defense-in-depth", () => {
|
||||
test("ignores a user-supplied tenant_id mapping and uses the connector value", () => {
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("malicious", "tenant_id"),
|
||||
makeMapping("feedback_text", "value_text"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("response_id", "submission_id"),
|
||||
makeMapping("", "source_type", "csv"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
const row = {
|
||||
malicious: "stolen-tenant",
|
||||
feedback_text: "x",
|
||||
question: "q1",
|
||||
response_id: "resp-1",
|
||||
timestamp: "2026-01-15",
|
||||
};
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings, TENANT);
|
||||
|
||||
expect(result!.tenant_id).toBe(TENANT);
|
||||
expect(result!.tenant_id).not.toBe("stolen-tenant");
|
||||
});
|
||||
|
||||
test("ignores a static tenant_id mapping", () => {
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("", "tenant_id", "stolen-tenant"),
|
||||
makeMapping("feedback_text", "value_text"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("response_id", "submission_id"),
|
||||
makeMapping("", "source_type", "csv"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", response_id: "resp-1", timestamp: "2026-01-15" },
|
||||
mappings,
|
||||
TENANT
|
||||
);
|
||||
|
||||
expect(result!.tenant_id).toBe(TENANT);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { TConnectorFieldMapping, THubTargetField } from "@formbricks/types/connector";
|
||||
import {
|
||||
TConnectorFieldMapping,
|
||||
THubFieldType,
|
||||
THubTargetField,
|
||||
ZHubFieldType,
|
||||
} from "@formbricks/types/connector";
|
||||
import { FeedbackRecordCreateParams } from "@/modules/hub";
|
||||
import { routeResponseValueTarget } from "./utils";
|
||||
|
||||
const NUMERIC_FIELDS = new Set<THubTargetField>(["value_number"]);
|
||||
const BOOLEAN_FIELDS = new Set<THubTargetField>(["value_boolean"]);
|
||||
const TIMESTAMP_FIELDS = new Set<THubTargetField>(["collected_at", "value_date"]);
|
||||
const JSON_FIELDS = new Set<THubTargetField>(["metadata"]);
|
||||
const ISO_DATE_OR_TIMESTAMP_REGEX =
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d))?$/;
|
||||
|
||||
const isValidIsoDateOrTimestamp = (value: string): boolean => {
|
||||
const match = ISO_DATE_OR_TIMESTAMP_REGEX.exec(value);
|
||||
if (!match) return false;
|
||||
|
||||
const [, year, month, day] = match.map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||
};
|
||||
|
||||
const coerceValue = (value: string, targetField: THubTargetField): string | number | boolean | undefined => {
|
||||
const trimmed = value.trim();
|
||||
@@ -24,6 +40,7 @@ const coerceValue = (value: string, targetField: THubTargetField): string | numb
|
||||
}
|
||||
|
||||
if (TIMESTAMP_FIELDS.has(targetField)) {
|
||||
if (!isValidIsoDateOrTimestamp(trimmed)) return undefined;
|
||||
const date = new Date(trimmed);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
}
|
||||
@@ -33,28 +50,40 @@ const coerceValue = (value: string, targetField: THubTargetField): string | numb
|
||||
|
||||
const resolveValue = (
|
||||
row: Record<string, string>,
|
||||
mapping: TConnectorFieldMapping
|
||||
mapping: TConnectorFieldMapping,
|
||||
effectiveTargetFieldId: THubTargetField
|
||||
): string | number | boolean | undefined => {
|
||||
if (mapping.staticValue) {
|
||||
if (mapping.staticValue === "$now" && TIMESTAMP_FIELDS.has(mapping.targetFieldId)) {
|
||||
if (mapping.staticValue === "$now" && TIMESTAMP_FIELDS.has(effectiveTargetFieldId)) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
return coerceValue(mapping.staticValue, mapping.targetFieldId);
|
||||
return coerceValue(mapping.staticValue, effectiveTargetFieldId);
|
||||
}
|
||||
|
||||
const rawValue = row[mapping.sourceFieldId];
|
||||
if (rawValue === undefined || rawValue === null) return undefined;
|
||||
|
||||
return coerceValue(rawValue, mapping.targetFieldId);
|
||||
return coerceValue(rawValue, effectiveTargetFieldId);
|
||||
};
|
||||
|
||||
const resolveFieldTypeForRow = (
|
||||
row: Record<string, string>,
|
||||
mappings: TConnectorFieldMapping[]
|
||||
): THubFieldType | null => {
|
||||
const mapping = mappings.find((m) => m.targetFieldId === "field_type");
|
||||
if (!mapping) return null;
|
||||
|
||||
const raw = mapping.staticValue ?? row[mapping.sourceFieldId];
|
||||
if (!raw) return null;
|
||||
|
||||
const parsed = ZHubFieldType.safeParse(raw.trim());
|
||||
return parsed.success ? parsed.data : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform a single CSV row into a FeedbackRecord using field mappings.
|
||||
*
|
||||
* Returns null if any of source_type, field_id, field_type, tenant_id are missing,
|
||||
* or if submission_id is mapped but resolves empty for this row (would break
|
||||
* idempotency on re-import). Falls back to a random UUID for submission_id only
|
||||
* when no mapping for it exists.
|
||||
* Returns null if field_id, field_type, tenant_id, or submission_id are missing.
|
||||
*/
|
||||
export const transformCsvRowToFeedbackRecord = (
|
||||
row: Record<string, string>,
|
||||
@@ -63,18 +92,37 @@ export const transformCsvRowToFeedbackRecord = (
|
||||
): FeedbackRecordCreateParams | null => {
|
||||
const record: Record<string, string | number | boolean | Record<string, unknown> | undefined> = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
const value = resolveValue(row, mapping);
|
||||
if (value === undefined) continue;
|
||||
const safeMappings = mappings.filter(
|
||||
(m) => m.targetFieldId !== "tenant_id" && m.targetFieldId !== "source_type"
|
||||
);
|
||||
record.source_type = "csv";
|
||||
|
||||
if (JSON_FIELDS.has(mapping.targetFieldId)) {
|
||||
const fieldType = resolveFieldTypeForRow(row, safeMappings);
|
||||
if (!fieldType) return null;
|
||||
|
||||
for (const mapping of safeMappings) {
|
||||
let effectiveTargetFieldId: THubTargetField;
|
||||
if (mapping.targetFieldId === "response_value") {
|
||||
try {
|
||||
record[mapping.targetFieldId] = typeof value === "string" ? JSON.parse(value) : value;
|
||||
effectiveTargetFieldId = routeResponseValueTarget(fieldType);
|
||||
} catch {
|
||||
record[mapping.targetFieldId] = { raw: value };
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
record[mapping.targetFieldId] = value;
|
||||
effectiveTargetFieldId = mapping.targetFieldId;
|
||||
}
|
||||
|
||||
const value = resolveValue(row, mapping, effectiveTargetFieldId);
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (JSON_FIELDS.has(effectiveTargetFieldId)) {
|
||||
try {
|
||||
record[effectiveTargetFieldId] = typeof value === "string" ? JSON.parse(value) : value;
|
||||
} catch {
|
||||
record[effectiveTargetFieldId] = { raw: value };
|
||||
}
|
||||
} else {
|
||||
record[effectiveTargetFieldId] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,12 +138,8 @@ export const transformCsvRowToFeedbackRecord = (
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!("submission_id" in record)) {
|
||||
const submissionMapped = mappings.some((m) => m.targetFieldId === "submission_id");
|
||||
if (submissionMapped) {
|
||||
return null;
|
||||
}
|
||||
record.submission_id = randomUUID();
|
||||
if (!record.submission_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return record as unknown as FeedbackRecordCreateParams;
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { type TConnectorFieldMappingCreateInput, ZHubFieldType } from "@formbricks/types/connector";
|
||||
import {
|
||||
formatCsvMissingMappedSourceColumns,
|
||||
formatMissingRequiredCsvFieldMappingsMessage,
|
||||
getMissingCsvMappedSourceColumns,
|
||||
getMissingRequiredCsvFieldMappings,
|
||||
getMissingRequiredCsvSourceColumns,
|
||||
routeResponseValueTarget,
|
||||
sanitizeCsvFieldMappings,
|
||||
} from "./utils";
|
||||
|
||||
describe("sanitizeCsvFieldMappings", () => {
|
||||
test("drops user-controlled tenant_id and source_type mappings", () => {
|
||||
const mappings: TConnectorFieldMappingCreateInput[] = [
|
||||
{ sourceFieldId: "tenant", targetFieldId: "tenant_id" },
|
||||
{ sourceFieldId: "type", targetFieldId: "source_type" },
|
||||
{ sourceFieldId: "question", targetFieldId: "field_id" },
|
||||
];
|
||||
|
||||
expect(sanitizeCsvFieldMappings(mappings)).toEqual([
|
||||
{ sourceFieldId: "question", targetFieldId: "field_id" },
|
||||
{ sourceFieldId: "", targetFieldId: "source_type", staticValue: "csv" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns undefined for empty input", () => {
|
||||
expect(sanitizeCsvFieldMappings(undefined)).toBeUndefined();
|
||||
expect(sanitizeCsvFieldMappings([])).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns only the static csv source_type mapping when input is all-protected", () => {
|
||||
const mappings: TConnectorFieldMappingCreateInput[] = [
|
||||
{ sourceFieldId: "tenant", targetFieldId: "tenant_id" },
|
||||
{ sourceFieldId: "type", targetFieldId: "source_type" },
|
||||
];
|
||||
|
||||
expect(sanitizeCsvFieldMappings(mappings)).toEqual([
|
||||
{ sourceFieldId: "", targetFieldId: "source_type", staticValue: "csv" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMissingRequiredCsvFieldMappings", () => {
|
||||
const requiredMappings: TConnectorFieldMappingCreateInput[] = [
|
||||
{ sourceFieldId: "response_id", targetFieldId: "submission_id" },
|
||||
{ sourceFieldId: "question_id", targetFieldId: "field_id" },
|
||||
{ sourceFieldId: "question", targetFieldId: "field_label" },
|
||||
{ sourceFieldId: "", targetFieldId: "field_type", staticValue: "text" },
|
||||
{ sourceFieldId: "answer", targetFieldId: "response_value" },
|
||||
];
|
||||
|
||||
test("returns no missing fields when all required CSV mappings are present", () => {
|
||||
expect(getMissingRequiredCsvFieldMappings(requiredMappings)).toEqual([]);
|
||||
});
|
||||
|
||||
test("requires submission_id but not source_id", () => {
|
||||
const mappings = requiredMappings.filter((mapping) => mapping.targetFieldId !== "submission_id");
|
||||
|
||||
expect(getMissingRequiredCsvFieldMappings(mappings)).toEqual(["submission_id"]);
|
||||
expect(getMissingRequiredCsvFieldMappings(requiredMappings)).not.toContain("source_id");
|
||||
});
|
||||
|
||||
test("does not require field_label", () => {
|
||||
const mappings = requiredMappings.filter((mapping) => mapping.targetFieldId !== "field_label");
|
||||
|
||||
expect(getMissingRequiredCsvFieldMappings(mappings)).not.toContain("field_label");
|
||||
});
|
||||
|
||||
test("treats an invalid static field_type as missing", () => {
|
||||
const mappings = requiredMappings.map((mapping) =>
|
||||
mapping.targetFieldId === "field_type" ? { ...mapping, staticValue: "invalid_type" } : mapping
|
||||
);
|
||||
|
||||
expect(getMissingRequiredCsvFieldMappings(mappings)).toContain("field_type");
|
||||
});
|
||||
|
||||
test("formats missing required mapping guidance without the legacy terse error", () => {
|
||||
expect(formatMissingRequiredCsvFieldMappingsMessage()).toBe(
|
||||
"This saved CSV mapping is incomplete. Edit the CSV mapping and choose a CSV column or fixed value for each required field before importing."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMissingCsvMappedSourceColumns", () => {
|
||||
test("returns mapped source-to-target pairs for missing CSV headers", () => {
|
||||
const missing = getMissingCsvMappedSourceColumns(
|
||||
[
|
||||
{ sourceFieldId: "source_id", targetFieldId: "submission_id" },
|
||||
{ sourceFieldId: "answer", targetFieldId: "response_value" },
|
||||
],
|
||||
["answer"]
|
||||
);
|
||||
|
||||
expect(missing).toEqual([{ sourceFieldId: "source_id", targetFieldId: "submission_id" }]);
|
||||
expect(formatCsvMissingMappedSourceColumns(missing)).toBe("source_id -> submission_id");
|
||||
});
|
||||
|
||||
test("reports multiple missing source columns separately from mapping details", () => {
|
||||
const missing = getMissingCsvMappedSourceColumns(
|
||||
[
|
||||
{ sourceFieldId: "source_id", targetFieldId: "submission_id" },
|
||||
{ sourceFieldId: "question_id", targetFieldId: "field_id" },
|
||||
{ sourceFieldId: "answer", targetFieldId: "response_value" },
|
||||
],
|
||||
["answer"]
|
||||
);
|
||||
|
||||
expect(formatCsvMissingMappedSourceColumns(missing)).toBe(
|
||||
"source_id -> submission_id, question_id -> field_id"
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores static-value mappings", () => {
|
||||
const missing = getMissingCsvMappedSourceColumns(
|
||||
[
|
||||
{ sourceFieldId: "", targetFieldId: "field_type", staticValue: "text" },
|
||||
{ sourceFieldId: "answer", targetFieldId: "response_value" },
|
||||
],
|
||||
["answer"]
|
||||
);
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMissingRequiredCsvSourceColumns", () => {
|
||||
test("returns missing source_field_id values for required mappings", () => {
|
||||
const missing = getMissingRequiredCsvSourceColumns(
|
||||
[
|
||||
{ sourceFieldId: "source_id", targetFieldId: "submission_id" },
|
||||
{ sourceFieldId: "question_id", targetFieldId: "field_id" },
|
||||
{ sourceFieldId: "optional_source", targetFieldId: "source_id" },
|
||||
{ sourceFieldId: "", targetFieldId: "field_type", staticValue: "text" },
|
||||
{ sourceFieldId: "answer", targetFieldId: "response_value" },
|
||||
],
|
||||
["optional_source", "answer"]
|
||||
);
|
||||
|
||||
expect(missing).toEqual(["source_id", "question_id"]);
|
||||
});
|
||||
|
||||
test("ignores missing optional mapped source columns", () => {
|
||||
const missing = getMissingRequiredCsvSourceColumns(
|
||||
[
|
||||
{ sourceFieldId: "response_id", targetFieldId: "submission_id" },
|
||||
{ sourceFieldId: "optional_source", targetFieldId: "source_id" },
|
||||
],
|
||||
["response_id"]
|
||||
);
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeResponseValueTarget", () => {
|
||||
test.each([
|
||||
["text", "value_text"],
|
||||
["categorical", "value_text"],
|
||||
["number", "value_number"],
|
||||
["nps", "value_number"],
|
||||
["csat", "value_number"],
|
||||
["ces", "value_number"],
|
||||
["rating", "value_number"],
|
||||
["boolean", "value_boolean"],
|
||||
["date", "value_date"],
|
||||
] as const)("routes %s to %s", (fieldType, expected) => {
|
||||
expect(routeResponseValueTarget(fieldType)).toBe(expected);
|
||||
});
|
||||
|
||||
test("covers every THubFieldType enum value", () => {
|
||||
for (const fieldType of ZHubFieldType.options) {
|
||||
expect(() => routeResponseValueTarget(fieldType)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { TConnectorFieldMappingCreateInput, THubFieldType } from "@formbricks/types/connector";
|
||||
import { ZHubFieldType } from "@formbricks/types/connector";
|
||||
import {
|
||||
CSV_HIDDEN_STATIC_MAPPINGS,
|
||||
CSV_PROTECTED_TARGET_IDS,
|
||||
CSV_REQUIRED_UI_FIELDS,
|
||||
} from "@/modules/ee/unify-feedback/sources/types";
|
||||
|
||||
export const sanitizeCsvFieldMappings = (
|
||||
fieldMappings: TConnectorFieldMappingCreateInput[] | undefined
|
||||
): TConnectorFieldMappingCreateInput[] | undefined => {
|
||||
if (!fieldMappings?.length) return undefined;
|
||||
|
||||
const userMappings = fieldMappings.filter((mapping) =>
|
||||
CSV_PROTECTED_TARGET_IDS.every((id) => mapping.targetFieldId !== id)
|
||||
);
|
||||
|
||||
return [...userMappings, ...(CSV_HIDDEN_STATIC_MAPPINGS as TConnectorFieldMappingCreateInput[])];
|
||||
};
|
||||
|
||||
type TCsvFieldMappingLike = {
|
||||
targetFieldId: string;
|
||||
sourceFieldId?: string;
|
||||
staticValue?: string | null;
|
||||
};
|
||||
|
||||
export interface TCsvMissingMappedSourceColumn {
|
||||
sourceFieldId: string;
|
||||
targetFieldId: string;
|
||||
}
|
||||
|
||||
export const getMissingCsvMappedSourceColumns = (
|
||||
fieldMappings: TCsvFieldMappingLike[] | undefined,
|
||||
csvHeaders: Iterable<string>
|
||||
): TCsvMissingMappedSourceColumn[] => {
|
||||
const headerSet = new Set(csvHeaders);
|
||||
const missingByPair = new Map<string, TCsvMissingMappedSourceColumn>();
|
||||
|
||||
for (const mapping of fieldMappings ?? []) {
|
||||
if (mapping.staticValue?.trim()) continue;
|
||||
if (!mapping.sourceFieldId) continue;
|
||||
if (headerSet.has(mapping.sourceFieldId)) continue;
|
||||
|
||||
const key = `${mapping.sourceFieldId}->${mapping.targetFieldId}`;
|
||||
missingByPair.set(key, {
|
||||
sourceFieldId: mapping.sourceFieldId,
|
||||
targetFieldId: mapping.targetFieldId,
|
||||
});
|
||||
}
|
||||
|
||||
return [...missingByPair.values()];
|
||||
};
|
||||
|
||||
export const formatCsvMissingMappedSourceColumns = (missing: TCsvMissingMappedSourceColumn[]): string =>
|
||||
missing.map(({ sourceFieldId, targetFieldId }) => `${sourceFieldId} -> ${targetFieldId}`).join(", ");
|
||||
|
||||
export const formatCsvMissingMappedSourceColumnNames = (missing: TCsvMissingMappedSourceColumn[]): string =>
|
||||
[...new Set(missing.map(({ sourceFieldId }) => sourceFieldId))].join(", ");
|
||||
|
||||
export const getMissingRequiredCsvSourceColumns = (
|
||||
fieldMappings: TCsvFieldMappingLike[] | undefined,
|
||||
csvHeaders: Iterable<string>
|
||||
): string[] => {
|
||||
const headerSet = new Set(csvHeaders);
|
||||
const missing = new Set<string>();
|
||||
|
||||
for (const requiredId of CSV_REQUIRED_UI_FIELDS) {
|
||||
const mapping = fieldMappings?.find((item) => item.targetFieldId === requiredId);
|
||||
if (!mapping?.sourceFieldId || mapping.staticValue?.trim()) continue;
|
||||
if (!headerSet.has(mapping.sourceFieldId)) {
|
||||
missing.add(mapping.sourceFieldId);
|
||||
}
|
||||
}
|
||||
|
||||
return [...missing];
|
||||
};
|
||||
|
||||
export const formatMissingRequiredCsvFieldMappingsMessage = (): string =>
|
||||
"This saved CSV mapping is incomplete. Edit the CSV mapping and choose a CSV column or fixed value for each required field before importing.";
|
||||
|
||||
export const getMissingRequiredCsvFieldMappings = (
|
||||
fieldMappings: TCsvFieldMappingLike[] | undefined
|
||||
): string[] => {
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const requiredId of CSV_REQUIRED_UI_FIELDS) {
|
||||
const mapping = fieldMappings?.find((item) => item.targetFieldId === requiredId);
|
||||
const resolved = Boolean(mapping?.sourceFieldId || mapping?.staticValue?.trim());
|
||||
|
||||
if (!resolved) {
|
||||
missing.push(requiredId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
requiredId === "field_type" &&
|
||||
mapping?.staticValue &&
|
||||
!ZHubFieldType.safeParse(mapping.staticValue).success
|
||||
) {
|
||||
missing.push(requiredId);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
};
|
||||
|
||||
export const routeResponseValueTarget = (
|
||||
fieldType: THubFieldType
|
||||
): "value_text" | "value_number" | "value_boolean" | "value_date" => {
|
||||
switch (fieldType) {
|
||||
case "text":
|
||||
case "categorical":
|
||||
return "value_text";
|
||||
case "number":
|
||||
case "nps":
|
||||
case "csat":
|
||||
case "ces":
|
||||
case "rating":
|
||||
return "value_number";
|
||||
case "boolean":
|
||||
return "value_boolean";
|
||||
case "date":
|
||||
return "value_date";
|
||||
default: {
|
||||
const _exhaustive: never = fieldType;
|
||||
throw new Error(`Unhandled field_type for response_value routing: ${String(_exhaustive)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Sende Feedback-Datensätze über die Management-API.",
|
||||
"auto_generated": "Automatisch generiert",
|
||||
"change_file": "Datei ändern",
|
||||
"click_load_sample_csv": "Klick auf 'Beispiel-CSV laden', um Spalten zu sehen",
|
||||
"clear_mapping": "Zuordnung löschen",
|
||||
"click_to_upload": "Zum Hochladen klicken",
|
||||
"collected_at": "Erfasst am",
|
||||
"configure_import": "Import konfigurieren",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Connector erfolgreich erstellt",
|
||||
"connector_deleted_successfully": "Connector erfolgreich gelöscht",
|
||||
"connector_duplicated_successfully": "Connector erfolgreich dupliziert",
|
||||
"connector_name": "Connector-Name",
|
||||
"connector_name_hint": "So wird dieser Connector in deinem Dashboard angezeigt. Wird automatisch aus dem hochgeladenen Dateinamen übernommen – du kannst es jederzeit ändern.",
|
||||
"connector_status_updated_successfully": "Connector-Status erfolgreich aktualisiert",
|
||||
"connector_updated_successfully": "Connector erfolgreich aktualisiert",
|
||||
"connectors": "Connectoren",
|
||||
"create_mapping": "Zuordnung erstellen",
|
||||
"created_by": "Erstellt von",
|
||||
"csv_advanced": "Erweitert",
|
||||
"csv_advanced_hint": "Weniger häufig verwendete Felder. Fülle sie aus, wenn relevant.",
|
||||
"csv_at_least_one_row": "Die CSV-Datei muss mindestens eine Datenzeile enthalten.",
|
||||
"csv_auto_mapped": "Automatisch zugeordnet",
|
||||
"csv_auto_mapped_tooltip": "Formbricks hat dies automatisch aus \"{column}\" zugeordnet. Du kannst es jederzeit ändern.",
|
||||
"csv_basic_required": "Basis",
|
||||
"csv_basic_required_hint": "Wähle eine CSV-Spalte aus oder lege einen festen Wert fest, der auf jede Zeile angewendet wird. Pflichtfelder sind mit einem Sternchen gekennzeichnet.",
|
||||
"csv_column_used_by": "Zugeordnet zu: {target}",
|
||||
"csv_columns": "CSV-Spalten",
|
||||
"csv_data_preview": "Datenvorschau",
|
||||
"csv_empty_column_headers": "Die CSV-Datei enthält leere Spaltenüberschriften. Alle Spalten müssen einen Namen haben.",
|
||||
"csv_file_too_large": "Die CSV-Datei ist zu groß. Die maximale Größe beträgt 2 MB.",
|
||||
"csv_files_only": "Nur CSV-Dateien",
|
||||
"csv_first_value": "Beispiel: {value}",
|
||||
"csv_fixed_value_action": "Festen Wert festlegen…",
|
||||
"csv_fixed_value_label": "Fester Wert: {value}",
|
||||
"csv_import": "CSV-Import",
|
||||
"csv_import_complete": "CSV-Import abgeschlossen: {successes} erfolgreich, {failures} fehlgeschlagen, {skipped} übersprungen",
|
||||
"csv_import_duplicate_warning": "Wenn Du die Daten zweimal importierst, entstehen doppelte Einträge.",
|
||||
"csv_import_duplicate_warning": "Dieser Import verwendet deine gespeicherte CSV-Zuordnung. Stelle sicher, dass diese Datei dieselbe Einreichungs-ID-Spalte enthält, damit wiederholte Importe derselben Quelleinreichung zugeordnet werden können.",
|
||||
"csv_inconsistent_columns": "Zeile {row} hat inkonsistente Spalten. Alle Zeilen müssen die gleichen Überschriften haben.",
|
||||
"csv_max_records": "Maximal {max} Einträge erlaubt.",
|
||||
"csv_now_label": "Jetzt (Zeitpunkt des Imports verwenden)",
|
||||
"csv_pick_column_placeholder": "Spalte wählen oder Wert festlegen…",
|
||||
"csv_required_fields_missing": "Bitte ordne die erforderlichen Felder zu, bevor du speicherst: {fields}",
|
||||
"csv_response_preview": "Beispiel: \"{sample}\" → gespeichert als {target}.",
|
||||
"csv_rows_count": "{count, plural, one {# Zeile} other {# Zeilen}}",
|
||||
"csv_sample_label": "CSV-Beispiel",
|
||||
"csv_saved_mapping_missing_columns": "In dieser Datei fehlen Spalten, die von der Feedback-Quelle benötigt werden. Bitte lade eine CSV-Datei hoch, die mit dem konfigurierten Format kompatibel ist.",
|
||||
"csv_source_context": "Quellkontext",
|
||||
"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",
|
||||
"default_connector_name_csv": "CSV-Import",
|
||||
"default_connector_name_formbricks": "Formbricks-Umfrage-Verbindung",
|
||||
"delete_feedback_record": "Feedback-Eintrag löschen",
|
||||
"delete_feedback_record_confirmation": "Dadurch wird der Feedback-Eintrag dauerhaft gelöscht und aus dem verbundenen Verzeichnis entfernt.",
|
||||
"delete_feedback_records_confirmation": "Dadurch werden {count} Feedback-Einträge dauerhaft gelöscht und aus dem verbundenen Verzeichnis entfernt.",
|
||||
"discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.",
|
||||
"discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?",
|
||||
"drop_a_field_here": "Ziehe ein Feld hierher",
|
||||
"drop_field_or": "Feld ablegen oder",
|
||||
"edit_csv_mapping": "CSV-Zuordnung bearbeiten",
|
||||
"edit_source_connection": "Quellverbindung bearbeiten",
|
||||
"enter_name_for_source": "Gib einen Namen für diese Quelle ein",
|
||||
"enter_value": "Wert eingeben...",
|
||||
"enum": "Aufzählung",
|
||||
"error_connector_field_mapping_duplicate": "Doppelte Feldzuordnung für diese Quelle",
|
||||
"error_connector_formbricks_mapping_duplicate": "Doppelte Fragenzuordnung für diese Quelle",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "Quellenname ist erforderlich",
|
||||
"error_connector_questions_required": "Wähle mindestens eine Frage aus",
|
||||
"error_connector_survey_required": "Wähle eine Umfrage aus",
|
||||
"failed_to_delete_feedback_records": "Feedback-Einträge konnten nicht gelöscht werden",
|
||||
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
|
||||
"feedback_date": "Aktuelles Datum",
|
||||
"feedback_directory": "Feedback-Verzeichnis",
|
||||
"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_fields": "Feedback-Eintragsfelder",
|
||||
"feedback_record_mcp": "Feedback-Datensatz MCP",
|
||||
"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",
|
||||
"feedback_records_refreshed": "Feedback-Einträge aktualisiert",
|
||||
"feedback_sources": "Feedback-Quellen",
|
||||
"feedback_sources_directory_access_multiple": "Neue Datensätze aus diesen Quellen werden gespeichert in: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Bisherige Antworten importieren",
|
||||
"import_historical_responses_description": "Importiere jetzt vorhandene Antworten aus dieser Umfrage.",
|
||||
"import_rows": "{count} Zeilen importieren",
|
||||
"import_via_source_name": "Import über „{sourceName}“",
|
||||
"import_via_source_name": "Als \"{sourceName}\" importieren",
|
||||
"importing_data": "Daten werden importiert...",
|
||||
"importing_historical_data": "Historische Daten werden importiert...",
|
||||
"invalid_enum_values": "Ungültige Werte in der Spalte, die {field} zugeordnet ist",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "Kein Feedback-Verzeichnis verknüpft",
|
||||
"no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.",
|
||||
"no_formbricks_surveys_available_description": "In diesem Workspace gibt es noch keine Umfragen. <surveyLink>Erstelle eine neue Umfrage</surveyLink>, um eine als Feedback-Quelle zu verwenden.",
|
||||
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
|
||||
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
|
||||
"optional": "Optional",
|
||||
"or_drag_and_drop": "oder per Drag & Drop",
|
||||
"question_type_not_supported": "Dieser Fragetyp wird nicht unterstützt",
|
||||
"refresh_feedback_records": "Feedback-Einträge aktualisieren",
|
||||
"refreshing_feedback_records": "Feedback-Einträge werden aktualisiert...",
|
||||
"request_feedback_source": "Quellen-Integration anfragen",
|
||||
"required": "Erforderlich",
|
||||
"save_changes": "Änderungen speichern",
|
||||
"search_feedback": "Feedback durchsuchen",
|
||||
"select_a_survey_to_see_questions": "Wähle eine Umfrage aus, um ihre Fragen zu sehen",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "Wert festlegen",
|
||||
"setup_connection": "Verbindung einrichten",
|
||||
"showing_count_loaded": "{count} Datensätze werden angezeigt",
|
||||
"showing_rows": "3 von {count} Zeilen werden angezeigt",
|
||||
"showing_rows": "Zeige {visible} von {total} Zeilen",
|
||||
"source": "Quelle",
|
||||
"source_connect_csv_description": "Feedback aus CSV-Dateien importieren",
|
||||
"source_connect_feedback_record_mcp_description": "Sende Feedback-Datensätze über die MCP-Integration.",
|
||||
"source_connect_formbricks_description": "Feedback aus Deinen Formbricks-Umfragen verbinden",
|
||||
"source_fields": "Quellfelder",
|
||||
"source_id": "Quell-ID",
|
||||
"source_name": "Quellenname",
|
||||
"source_type": "Quellentyp",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "Auto-generated",
|
||||
"change_file": "Change file",
|
||||
"click_load_sample_csv": "Click 'Load sample CSV' to see columns",
|
||||
"clear_mapping": "Clear mapping",
|
||||
"click_to_upload": "Click to upload",
|
||||
"collected_at": "Collected At",
|
||||
"configure_import": "Configure import",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Connector created successfully",
|
||||
"connector_deleted_successfully": "Connector deleted successfully",
|
||||
"connector_duplicated_successfully": "Connector duplicated successfully",
|
||||
"connector_name": "Connector Name",
|
||||
"connector_name_hint": "How this connector appears in your dashboard. Auto-filled from the uploaded filename — edit anytime.",
|
||||
"connector_status_updated_successfully": "Connector status updated successfully",
|
||||
"connector_updated_successfully": "Connector updated successfully",
|
||||
"connectors": "Connectors",
|
||||
"create_mapping": "Create mapping",
|
||||
"created_by": "Created by",
|
||||
"csv_advanced": "Advanced",
|
||||
"csv_advanced_hint": "Less common fields. Set them when relevant.",
|
||||
"csv_at_least_one_row": "CSV must contain at least one data row.",
|
||||
"csv_auto_mapped": "Auto-mapped",
|
||||
"csv_auto_mapped_tooltip": "Formbricks mapped this from \"{column}\" automatically. You can change it anytime.",
|
||||
"csv_basic_required": "Basic",
|
||||
"csv_basic_required_hint": "Pick a CSV column, or set a fixed value applied to every row. Required fields are marked with an asterisk.",
|
||||
"csv_column_used_by": "Mapped to: {target}",
|
||||
"csv_columns": "CSV Columns",
|
||||
"csv_data_preview": "Data preview",
|
||||
"csv_empty_column_headers": "CSV contains empty column headers. All columns must have a name.",
|
||||
"csv_file_too_large": "CSV file is too large. Maximum size is 2MB.",
|
||||
"csv_files_only": "CSV files only",
|
||||
"csv_first_value": "Example: {value}",
|
||||
"csv_fixed_value_action": "Set a fixed value…",
|
||||
"csv_fixed_value_label": "Fixed value: {value}",
|
||||
"csv_import": "CSV Import",
|
||||
"csv_import_complete": "CSV import complete: {successes} succeeded, {failures} failed, {skipped} skipped",
|
||||
"csv_import_duplicate_warning": "Importing data twice will create duplicate records.",
|
||||
"csv_import_duplicate_warning": "This import uses your saved CSV mapping. Make sure this file includes the same Submission ID column so repeated imports can be matched to the same source submission.",
|
||||
"csv_inconsistent_columns": "Row {row} has inconsistent columns. All rows must have the same headers.",
|
||||
"csv_max_records": "Maximum {max} records allowed.",
|
||||
"csv_now_label": "Now (use the import time)",
|
||||
"csv_pick_column_placeholder": "Pick a column or set a value…",
|
||||
"csv_required_fields_missing": "Please map required fields before saving: {fields}",
|
||||
"csv_response_preview": "Sample: \"{sample}\" → stored as {target}.",
|
||||
"csv_rows_count": "{count, plural, one {# row} other {# rows}}",
|
||||
"csv_sample_label": "Sample CSV",
|
||||
"csv_saved_mapping_missing_columns": "This file is missing columns needed by the Feedback Source, please upload a CSV compatible with the configured format.",
|
||||
"csv_source_context": "Source Context",
|
||||
"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",
|
||||
"default_connector_name_csv": "CSV Import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey Connection",
|
||||
"delete_feedback_record": "Delete feedback record",
|
||||
"delete_feedback_record_confirmation": "This will permanently delete the feedback record and remove it from the connected directory.",
|
||||
"delete_feedback_records_confirmation": "This will permanently delete {count} feedback records and remove them from the connected directory.",
|
||||
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
|
||||
"discard_feedback_record_changes_title": "Discard unsaved changes?",
|
||||
"drop_a_field_here": "Drop a field here",
|
||||
"drop_field_or": "Drop field or",
|
||||
"edit_csv_mapping": "Edit CSV mapping",
|
||||
"edit_source_connection": "Edit Source Connection",
|
||||
"enter_name_for_source": "Enter a name for this source",
|
||||
"enter_value": "Enter value...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Duplicate field mapping for this source",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplicate question mapping for this source",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "Source name is required",
|
||||
"error_connector_questions_required": "Select at least one question",
|
||||
"error_connector_survey_required": "Select a survey",
|
||||
"failed_to_delete_feedback_records": "Failed to delete feedback records",
|
||||
"failed_to_load_feedback_records": "Failed to load feedback records",
|
||||
"feedback_date": "Current date",
|
||||
"feedback_directory": "Feedback Directory",
|
||||
"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_fields": "Feedback Record Fields",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"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",
|
||||
"feedback_records_refreshed": "Feedback records refreshed",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "Import {count} rows",
|
||||
"import_via_source_name": "Import via \"{sourceName}\"",
|
||||
"import_via_source_name": "Import as \"{sourceName}\"",
|
||||
"importing_data": "Importing data...",
|
||||
"importing_historical_data": "Importing historical data...",
|
||||
"invalid_enum_values": "Invalid values in column mapped to {field}",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "No feedback directory linked",
|
||||
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
|
||||
"no_formbricks_surveys_available_description": "There are no surveys in this workspace yet. <surveyLink>Create a new survey</surveyLink> to use it as a feedback source.",
|
||||
"no_source_fields_loaded": "No source fields loaded yet",
|
||||
"no_sources_connected": "No sources connected yet. Add a source to get started.",
|
||||
"optional": "Optional",
|
||||
"or_drag_and_drop": "or drag and drop",
|
||||
"question_type_not_supported": "This question type is not supported",
|
||||
"refresh_feedback_records": "Refresh feedback records",
|
||||
"refreshing_feedback_records": "Refreshing feedback records...",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "Required",
|
||||
"save_changes": "Save changes",
|
||||
"search_feedback": "Search feedback",
|
||||
"select_a_survey_to_see_questions": "Select a survey to see its questions",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "set value",
|
||||
"setup_connection": "Setup connection",
|
||||
"showing_count_loaded": "Showing {count} records",
|
||||
"showing_rows": "Showing 3 of {count} rows",
|
||||
"showing_rows": "Showing {visible} of {total} rows",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "Import feedback from CSV files",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "Connect feedback from your Formbricks surveys",
|
||||
"source_fields": "Source Fields",
|
||||
"source_id": "Source ID",
|
||||
"source_name": "Source Name",
|
||||
"source_type": "Source Type",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Envía registros de feedback mediante la API de gestión.",
|
||||
"auto_generated": "Generado automáticamente",
|
||||
"change_file": "Cambiar archivo",
|
||||
"click_load_sample_csv": "Haz clic en 'Cargar CSV de muestra' para ver las columnas",
|
||||
"clear_mapping": "Borrar asignación",
|
||||
"click_to_upload": "Haz clic para subir",
|
||||
"collected_at": "Recopilado el",
|
||||
"configure_import": "Configurar importación",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Conector creado correctamente",
|
||||
"connector_deleted_successfully": "Conector eliminado correctamente",
|
||||
"connector_duplicated_successfully": "Conector duplicado correctamente",
|
||||
"connector_name": "Nombre del conector",
|
||||
"connector_name_hint": "Cómo aparece este conector en tu panel. Se rellena automáticamente con el nombre del archivo subido — edítalo cuando quieras.",
|
||||
"connector_status_updated_successfully": "Estado del conector actualizado correctamente",
|
||||
"connector_updated_successfully": "Conector actualizado correctamente",
|
||||
"connectors": "Conectores",
|
||||
"create_mapping": "Crear asignación",
|
||||
"created_by": "Creado por",
|
||||
"csv_advanced": "Avanzado",
|
||||
"csv_advanced_hint": "Campos menos comunes. Configúralos cuando sean relevantes.",
|
||||
"csv_at_least_one_row": "El CSV debe contener al menos una fila de datos.",
|
||||
"csv_auto_mapped": "Mapeado automáticamente",
|
||||
"csv_auto_mapped_tooltip": "Formbricks asignó esto automáticamente desde \"{column}\". Puedes cambiarlo en cualquier momento.",
|
||||
"csv_basic_required": "Básico",
|
||||
"csv_basic_required_hint": "Elige una columna del CSV o establece un valor fijo que se aplicará a todas las filas. Los campos obligatorios están marcados con un asterisco.",
|
||||
"csv_column_used_by": "Mapeado a: {target}",
|
||||
"csv_columns": "Columnas CSV",
|
||||
"csv_data_preview": "Vista previa de datos",
|
||||
"csv_empty_column_headers": "El CSV contiene encabezados de columna vacíos. Todas las columnas deben tener un nombre.",
|
||||
"csv_file_too_large": "El archivo CSV es demasiado grande. El tamaño máximo es de 2 MB.",
|
||||
"csv_files_only": "Solo archivos CSV",
|
||||
"csv_first_value": "Ejemplo: {value}",
|
||||
"csv_fixed_value_action": "Establecer un valor fijo…",
|
||||
"csv_fixed_value_label": "Valor fijo: {value}",
|
||||
"csv_import": "Importación CSV",
|
||||
"csv_import_complete": "Importación de CSV completada: {successes} correctas, {failures} fallidas, {skipped} omitidas",
|
||||
"csv_import_duplicate_warning": "Importar datos dos veces creará registros duplicados.",
|
||||
"csv_import_duplicate_warning": "Esta importación utiliza tu mapeo de CSV guardado. Asegúrate de que este archivo incluye la misma columna de ID de envío para que las importaciones repetidas puedan asociarse al mismo envío de origen.",
|
||||
"csv_inconsistent_columns": "La fila {row} tiene columnas inconsistentes. Todas las filas deben tener los mismos encabezados.",
|
||||
"csv_max_records": "Máximo de {max} registros permitidos.",
|
||||
"csv_now_label": "Ahora (usar la hora de importación)",
|
||||
"csv_pick_column_placeholder": "Elige una columna o establece un valor…",
|
||||
"csv_required_fields_missing": "Por favor, mapea los campos obligatorios antes de guardar: {fields}",
|
||||
"csv_response_preview": "Ejemplo: \"{sample}\" → almacenado como {target}.",
|
||||
"csv_rows_count": "{count, plural, one {# fila} other {# filas}}",
|
||||
"csv_sample_label": "CSV de ejemplo",
|
||||
"csv_saved_mapping_missing_columns": "Este archivo no tiene las columnas necesarias para la fuente de comentarios. Por favor, sube un CSV compatible con el formato configurado.",
|
||||
"csv_source_context": "Contexto de origen",
|
||||
"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",
|
||||
"default_connector_name_csv": "Importación CSV",
|
||||
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
|
||||
"delete_feedback_record": "Eliminar registro de comentarios",
|
||||
"delete_feedback_record_confirmation": "Esto eliminará permanentemente el registro de comentarios y lo quitará del directorio conectado.",
|
||||
"delete_feedback_records_confirmation": "Esto eliminará permanentemente {count} registros de comentarios y los quitará del directorio conectado.",
|
||||
"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?",
|
||||
"drop_a_field_here": "Suelta un campo aquí",
|
||||
"drop_field_or": "Suelta el campo o",
|
||||
"edit_csv_mapping": "Editar mapeo de CSV",
|
||||
"edit_source_connection": "Editar conexión de origen",
|
||||
"enter_name_for_source": "Introduce un nombre para este origen",
|
||||
"enter_value": "Introduce un valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeo de campo duplicado para esta fuente",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeo de pregunta duplicado para esta fuente",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "El nombre de origen es obligatorio",
|
||||
"error_connector_questions_required": "Selecciona al menos una pregunta",
|
||||
"error_connector_survey_required": "Selecciona una encuesta",
|
||||
"failed_to_delete_feedback_records": "No se pudieron eliminar los registros de comentarios",
|
||||
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
|
||||
"feedback_date": "Fecha actual",
|
||||
"feedback_directory": "Directorio 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_fields": "Campos de registro de comentarios",
|
||||
"feedback_record_mcp": "MCP de registros de feedback",
|
||||
"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",
|
||||
"feedback_records_refreshed": "Registros de comentarios actualizados",
|
||||
"feedback_sources": "Fuentes de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Los nuevos registros de estas fuentes se almacenarán en: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Importar respuestas históricas",
|
||||
"import_historical_responses_description": "Importa las respuestas existentes de esta encuesta ahora.",
|
||||
"import_rows": "Importar {count} filas",
|
||||
"import_via_source_name": "Importar mediante \"{sourceName}\"",
|
||||
"import_via_source_name": "Importar como \"{sourceName}\"",
|
||||
"importing_data": "Importando datos...",
|
||||
"importing_historical_data": "Importando datos históricos...",
|
||||
"invalid_enum_values": "Valores no válidos en la columna asignada a {field}",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "No hay ningún directorio de feedback vinculado",
|
||||
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
|
||||
"no_formbricks_surveys_available_description": "Todavía no hay encuestas en este espacio de trabajo. <surveyLink>Crea una nueva encuesta</surveyLink> para usar una como fuente de feedback.",
|
||||
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
|
||||
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
|
||||
"optional": "Opcional",
|
||||
"or_drag_and_drop": "o arrastra y suelta",
|
||||
"question_type_not_supported": "Este tipo de pregunta no es compatible",
|
||||
"refresh_feedback_records": "Actualizar los registros de comentarios",
|
||||
"refreshing_feedback_records": "Actualizando registros de comentarios...",
|
||||
"request_feedback_source": "Solicitar integración de fuente",
|
||||
"required": "Obligatorio",
|
||||
"save_changes": "Guardar cambios",
|
||||
"search_feedback": "Buscar feedback",
|
||||
"select_a_survey_to_see_questions": "Selecciona una encuesta para ver sus preguntas",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "establecer valor",
|
||||
"setup_connection": "Configurar conexión",
|
||||
"showing_count_loaded": "Mostrando {count} registros",
|
||||
"showing_rows": "Mostrando 3 de {count} filas",
|
||||
"showing_rows": "Mostrando {visible} de {total} filas",
|
||||
"source": "origen",
|
||||
"source_connect_csv_description": "Importar feedback desde archivos CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envía registros de feedback a través de la integración MCP.",
|
||||
"source_connect_formbricks_description": "Conectar feedback de tus encuestas de Formbricks",
|
||||
"source_fields": "Campos de origen",
|
||||
"source_id": "ID de fuente",
|
||||
"source_name": "Nombre de origen",
|
||||
"source_type": "Tipo de fuente",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Envoyer des enregistrements de feedback via l'API de gestion.",
|
||||
"auto_generated": "Généré automatiquement",
|
||||
"change_file": "Changer de fichier",
|
||||
"click_load_sample_csv": "Clique sur « Charger un exemple CSV » pour voir les colonnes",
|
||||
"clear_mapping": "Effacer le mappage",
|
||||
"click_to_upload": "Clique pour charger",
|
||||
"collected_at": "Collecté le",
|
||||
"configure_import": "Configurer l'importation",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Connecteur créé avec succès",
|
||||
"connector_deleted_successfully": "Connecteur supprimé avec succès",
|
||||
"connector_duplicated_successfully": "Connecteur dupliqué avec succès",
|
||||
"connector_name": "Nom du connecteur",
|
||||
"connector_name_hint": "Comment ce connecteur apparaît dans ton tableau de bord. Rempli automatiquement à partir du nom du fichier importé — modifiable à tout moment.",
|
||||
"connector_status_updated_successfully": "Statut du connecteur mis à jour avec succès",
|
||||
"connector_updated_successfully": "Connecteur mis à jour avec succès",
|
||||
"connectors": "Connecteurs",
|
||||
"create_mapping": "Créer un mappage",
|
||||
"created_by": "Créé par",
|
||||
"csv_advanced": "Avancé",
|
||||
"csv_advanced_hint": "Champs moins courants. Configure-les si nécessaire.",
|
||||
"csv_at_least_one_row": "Le CSV doit contenir au moins une ligne de données.",
|
||||
"csv_auto_mapped": "Mappé automatiquement",
|
||||
"csv_auto_mapped_tooltip": "Formbricks a automatiquement mappé ceci depuis \"{column}\". Vous pouvez le modifier à tout moment.",
|
||||
"csv_basic_required": "Basique",
|
||||
"csv_basic_required_hint": "Sélectionnez une colonne CSV ou définissez une valeur fixe appliquée à chaque ligne. Les champs obligatoires sont marqués d'un astérisque.",
|
||||
"csv_column_used_by": "Mappé vers : {target}",
|
||||
"csv_columns": "Colonnes CSV",
|
||||
"csv_data_preview": "Aperçu des données",
|
||||
"csv_empty_column_headers": "Le CSV contient des en-têtes de colonnes vides. Toutes les colonnes doivent avoir un nom.",
|
||||
"csv_file_too_large": "Le fichier CSV est trop volumineux. La taille maximale est de 2 Mo.",
|
||||
"csv_files_only": "Fichiers CSV uniquement",
|
||||
"csv_first_value": "Exemple : {value}",
|
||||
"csv_fixed_value_action": "Définir une valeur fixe…",
|
||||
"csv_fixed_value_label": "Valeur fixe : {value}",
|
||||
"csv_import": "Importation CSV",
|
||||
"csv_import_complete": "Importation CSV terminée : {successes} réussies, {failures} échouées, {skipped} ignorées",
|
||||
"csv_import_duplicate_warning": "Importer les données deux fois créera des enregistrements en double.",
|
||||
"csv_import_duplicate_warning": "Cet import utilise votre mappage CSV enregistré. Assurez-vous que ce fichier inclut la même colonne d'ID de soumission afin que les imports répétés puissent être associés à la même soumission source.",
|
||||
"csv_inconsistent_columns": "La ligne {row} a des colonnes incohérentes. Toutes les lignes doivent avoir les mêmes en-têtes.",
|
||||
"csv_max_records": "Maximum {max} enregistrements autorisés.",
|
||||
"csv_now_label": "Maintenant (utilise l'heure d'importation)",
|
||||
"csv_pick_column_placeholder": "Choisis une colonne ou définis une valeur…",
|
||||
"csv_required_fields_missing": "Merci de mapper les champs requis avant d'enregistrer : {fields}",
|
||||
"csv_response_preview": "Exemple : « {sample} » → stocké comme {target}.",
|
||||
"csv_rows_count": "{count, plural, one {# ligne} other {# lignes}}",
|
||||
"csv_sample_label": "Exemple de CSV",
|
||||
"csv_saved_mapping_missing_columns": "Ce fichier ne contient pas les colonnes requises par la source de feedback. Veuillez importer un fichier CSV compatible avec le format configuré.",
|
||||
"csv_source_context": "Contexte de la source",
|
||||
"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é",
|
||||
"default_connector_name_csv": "Importation CSV",
|
||||
"default_connector_name_formbricks": "Connexion de sondage Formbricks",
|
||||
"delete_feedback_record": "Supprimer l'enregistrement de commentaire",
|
||||
"delete_feedback_record_confirmation": "Cela supprimera définitivement l'enregistrement de commentaire et le retirera du répertoire connecté.",
|
||||
"delete_feedback_records_confirmation": "Cela supprimera définitivement {count} enregistrements de commentaires et les retirera du répertoire connecté.",
|
||||
"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 ?",
|
||||
"drop_a_field_here": "Déposez un champ ici",
|
||||
"drop_field_or": "Déposez un champ ou",
|
||||
"edit_csv_mapping": "Modifier le mappage CSV",
|
||||
"edit_source_connection": "Modifier la connexion source",
|
||||
"enter_name_for_source": "Entrez un nom pour cette source",
|
||||
"enter_value": "Saisir une valeur...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mappage de champ en double pour cette source",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mappage de question en double pour cette source",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "Le nom de la source est requis",
|
||||
"error_connector_questions_required": "Sélectionnez au moins une question",
|
||||
"error_connector_survey_required": "Sélectionnez une enquête",
|
||||
"failed_to_delete_feedback_records": "Échec de la suppression des enregistrements de commentaires",
|
||||
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
|
||||
"feedback_date": "Date actuelle",
|
||||
"feedback_directory": "Répertoire de retours",
|
||||
"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_fields": "Champs d'enregistrement de feedback",
|
||||
"feedback_record_mcp": "MCP d'enregistrement de feedback",
|
||||
"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}",
|
||||
"feedback_records_refreshed": "Enregistrements de feedback actualisés",
|
||||
"feedback_sources": "Sources de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Les nouveaux enregistrements de ces sources seront stockés dans : {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Importer les réponses historiques",
|
||||
"import_historical_responses_description": "Importe les réponses existantes de cette enquête maintenant.",
|
||||
"import_rows": "Importer {count} lignes",
|
||||
"import_via_source_name": "Importer via \"{sourceName}\"",
|
||||
"import_via_source_name": "Importer en tant que « {sourceName} »",
|
||||
"importing_data": "Importation des données...",
|
||||
"importing_historical_data": "Importation des données historiques...",
|
||||
"invalid_enum_values": "Valeurs non valides dans la colonne mappée à {field}",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "Aucun répertoire de feedback lié",
|
||||
"no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.",
|
||||
"no_formbricks_surveys_available_description": "Il n’y a pas encore de sondages dans cet espace de travail. <surveyLink>Créez une nouvelle enquête</surveyLink> pour en utiliser une comme source de feedback.",
|
||||
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
|
||||
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
|
||||
"optional": "Facultatif",
|
||||
"or_drag_and_drop": "ou glisser-déposer",
|
||||
"question_type_not_supported": "Ce type de question n'est pas pris en charge",
|
||||
"refresh_feedback_records": "Actualiser les enregistrements de retours",
|
||||
"refreshing_feedback_records": "Actualisation des enregistrements de feedback...",
|
||||
"request_feedback_source": "Demander une intégration de source",
|
||||
"required": "Requis",
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"search_feedback": "Rechercher des retours",
|
||||
"select_a_survey_to_see_questions": "Sélectionnez une enquête pour voir ses questions",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "définir la valeur",
|
||||
"setup_connection": "Configurer la connexion",
|
||||
"showing_count_loaded": "Affichage de {count} enregistrements",
|
||||
"showing_rows": "Affichage de 3 sur {count} lignes",
|
||||
"showing_rows": "Affichage de {visible} lignes sur {total}",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "Importer des feedbacks depuis des fichiers CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envoyer des enregistrements de feedback via l'intégration MCP.",
|
||||
"source_connect_formbricks_description": "Connecter les feedbacks de vos enquêtes Formbricks",
|
||||
"source_fields": "Champs source",
|
||||
"source_id": "Identifiant de la source",
|
||||
"source_name": "Nom de la source",
|
||||
"source_type": "Type de source",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Visszajelzési rekordok küldése a Management API használatával.",
|
||||
"auto_generated": "Automatikusan generált",
|
||||
"change_file": "Fájl módosítása",
|
||||
"click_load_sample_csv": "Kattintson a 'Minta CSV betöltése' gombra az oszlopok megtekintéséhez",
|
||||
"clear_mapping": "Leképezés törlése",
|
||||
"click_to_upload": "Kattintson a feltöltéshez",
|
||||
"collected_at": "Gyűjtve",
|
||||
"configure_import": "Importálás konfigurálása",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Csatlakozó sikeresen létrehozva",
|
||||
"connector_deleted_successfully": "Csatlakozó sikeresen törölve",
|
||||
"connector_duplicated_successfully": "Csatlakozó sikeresen duplikálva",
|
||||
"connector_name": "Csatlakozó neve",
|
||||
"connector_name_hint": "Így jelenik meg ez a csatlakozó az Ön irányítópultján. Automatikusan kitöltésre kerül a feltöltött fájlnévből — bármikor szerkeszthető.",
|
||||
"connector_status_updated_successfully": "Csatlakozó állapota sikeresen frissítve",
|
||||
"connector_updated_successfully": "Csatlakozó sikeresen frissítve",
|
||||
"connectors": "Csatlakozók",
|
||||
"create_mapping": "Leképezés létrehozása",
|
||||
"created_by": "Létrehozta",
|
||||
"csv_advanced": "Speciális",
|
||||
"csv_advanced_hint": "Kevésbé gyakori mezők. Állítsa be őket, amikor szükséges.",
|
||||
"csv_at_least_one_row": "A CSV-nek legalább egy adatsort kell tartalmaznia.",
|
||||
"csv_auto_mapped": "Automatikusan leképezve",
|
||||
"csv_auto_mapped_tooltip": "A Formbricks ezt automatikusan leképezte a(z) \"{column}\" oszlopból. Bármikor megváltoztathatod.",
|
||||
"csv_basic_required": "Alapvető",
|
||||
"csv_basic_required_hint": "Válasszon egy CSV oszlopot, vagy állítson be egy rögzített értéket, amely minden sorra érvényes. A kötelező mezőket csillaggal jelöljük.",
|
||||
"csv_column_used_by": "Leképezve ide: {target}",
|
||||
"csv_columns": "CSV oszlopok",
|
||||
"csv_data_preview": "Adatelőnézet",
|
||||
"csv_empty_column_headers": "A CSV üres oszlopfejléceket tartalmaz. Minden oszlopnak rendelkeznie kell névvel.",
|
||||
"csv_file_too_large": "A CSV fájl túl nagy. A maximális méret 2 MB.",
|
||||
"csv_files_only": "Csak CSV fájlok",
|
||||
"csv_first_value": "Példa: {value}",
|
||||
"csv_fixed_value_action": "Rögzített érték beállítása…",
|
||||
"csv_fixed_value_label": "Rögzített érték: {value}",
|
||||
"csv_import": "CSV importálás",
|
||||
"csv_import_complete": "CSV importálás befejezve: {successes} sikeres, {failures} sikertelen, {skipped} kihagyva",
|
||||
"csv_import_duplicate_warning": "Az adatok kétszeri importálása duplikált rekordokat hoz létre.",
|
||||
"csv_import_duplicate_warning": "Ez az importálás az Ön mentett CSV leképezését használja. Győződjön meg arról, hogy ez a fájl tartalmazza ugyanazt a Beküldési azonosító oszlopot, így az ismételt importálások ugyanahhoz a forrás beküldéshez rendelhetők.",
|
||||
"csv_inconsistent_columns": "A(z) {row}. sor inkonzisztens oszlopokat tartalmaz. Minden sornak ugyanazokkal a fejlécekkel kell rendelkeznie.",
|
||||
"csv_max_records": "Maximum {max} rekord engedélyezett.",
|
||||
"csv_now_label": "Most (az importálás időpontjának használata)",
|
||||
"csv_pick_column_placeholder": "Válasszon oszlopot vagy állítson be értéket…",
|
||||
"csv_required_fields_missing": "Kérjük, a mentés előtt képezze le a kötelező mezőket: {fields}",
|
||||
"csv_response_preview": "Minta: \"{sample}\" → tárolva mint {target}.",
|
||||
"csv_rows_count": "{count, plural, one {# sor} other {# sor}}",
|
||||
"csv_sample_label": "Minta CSV",
|
||||
"csv_saved_mapping_missing_columns": "Ez a fájl nem tartalmazza a Visszajelzési Forrás által megkövetelt oszlopokat. Kérem, töltsön fel egy CSV fájlt, amely kompatibilis a beállított formátummal.",
|
||||
"csv_source_context": "Forráskontextus",
|
||||
"csv_source_context_hint": "Meghatározza, honnan származik ez a visszajelzési köteg.",
|
||||
"csv_unmapped_columns": "Le nem képezett oszlopok ({count}): {columns}",
|
||||
"csv_unmapped_columns_explainer": "Ezeket az oszlopokat egyetlen visszajelzési rekord mező sem használja. Az importáláskor figyelmen kívül maradnak.",
|
||||
"custom_source_type": "Egyéni forrástípus",
|
||||
"custom_source_type_placeholder": "Adja meg az egyéni forrástípust",
|
||||
"default_connector_name_csv": "CSV importálás",
|
||||
"default_connector_name_formbricks": "Formbricks kérdőív kapcsolat",
|
||||
"delete_feedback_record": "Visszajelzési bejegyzés törlése",
|
||||
"delete_feedback_record_confirmation": "Ez véglegesen törli a visszajelzési bejegyzést, és eltávolítja a csatlakoztatott könyvtárból.",
|
||||
"delete_feedback_records_confirmation": "Ez véglegesen töröl {count} visszajelzési bejegyzést, és eltávolítja őket a csatlakoztatott könyvtárból.",
|
||||
"discard_feedback_record_changes_description": "A módosítások elvesznek, ha bezárja ezt a fiókot.",
|
||||
"discard_feedback_record_changes_title": "Elveti a nem mentett módosításokat?",
|
||||
"drop_a_field_here": "Húzz ide egy mezőt",
|
||||
"drop_field_or": "Húzz ide egy mezőt vagy",
|
||||
"edit_csv_mapping": "CSV leképezés szerkesztése",
|
||||
"edit_source_connection": "Forráskapcsolat szerkesztése",
|
||||
"enter_name_for_source": "Adj nevet ennek a forrásnak",
|
||||
"enter_value": "Érték megadása...",
|
||||
"enum": "felsorolás",
|
||||
"error_connector_field_mapping_duplicate": "Duplikált mezőleképezés ehhez a forráshoz",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplikált kérdésleképezés ehhez a forráshoz",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "A forrás neve kötelező",
|
||||
"error_connector_questions_required": "Válasszon ki legalább egy kérdést",
|
||||
"error_connector_survey_required": "Válasszon ki egy felmérést",
|
||||
"failed_to_delete_feedback_records": "A visszajelzési rekordok törlése sikertelen",
|
||||
"failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat",
|
||||
"feedback_date": "Aktuális dátum",
|
||||
"feedback_directory": "Visszajelzési könyvtár",
|
||||
"feedback_record_created_successfully": "A visszajelzési rekord sikeresen létrehozva",
|
||||
"feedback_record_deleted_successfully": "A visszajelzési bejegyzés sikeresen törölve",
|
||||
"feedback_record_details": "A visszajelzési rekord részletei",
|
||||
"feedback_record_details_description": "Tekintse át és frissítse a visszajelzési rekordmezőket.",
|
||||
"feedback_record_fields": "Visszajelzési rekord mezők",
|
||||
"feedback_record_mcp": "Visszajelzési rekord MCP",
|
||||
"feedback_record_updated_successfully": "A visszajelzési rekord sikeresen frissítve",
|
||||
"feedback_record_value_required": "A kiválasztott mezőtípushoz értéket kell megadni",
|
||||
"feedback_records": "Visszajelzési rekordok",
|
||||
"feedback_records_deleted_successfully": "{count} visszajelzési bejegyzés törölve",
|
||||
"feedback_records_partially_deleted": "{succeeded} / {total} visszajelzési rekord törölve",
|
||||
"feedback_records_refreshed": "Visszajelzési rekordok frissítve",
|
||||
"feedback_sources": "Visszajelzési források",
|
||||
"feedback_sources_directory_access_multiple": "Az ezekből a forrásokból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Korábbi válaszok importálása",
|
||||
"import_historical_responses_description": "Meglévő válaszok importálása ebből a felmérésből most.",
|
||||
"import_rows": "{count} sor importálása",
|
||||
"import_via_source_name": "Importálás a következőn keresztül: \"{sourceName}\"",
|
||||
"import_via_source_name": "Importálás mint \"{sourceName}\"",
|
||||
"importing_data": "Adatok importálása...",
|
||||
"importing_historical_data": "Történeti adatok importálása...",
|
||||
"invalid_enum_values": "Érvénytelen értékek a(z) {field} mezőhöz rendelt oszlopban",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "Nincs visszajelzési könyvtár kapcsolva",
|
||||
"no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.",
|
||||
"no_formbricks_surveys_available_description": "Ebben a munkaterületen még nincsenek kérdőívek. <surveyLink>Hozz létre egy új kérdőívet</surveyLink>, hogy visszajelzési forrásként használhass egyet.",
|
||||
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
|
||||
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
|
||||
"optional": "Elhagyható",
|
||||
"or_drag_and_drop": "vagy húzd ide",
|
||||
"question_type_not_supported": "Ez a kérdéstípus nem támogatott",
|
||||
"refresh_feedback_records": "Visszajelzési rekordok frissítése",
|
||||
"refreshing_feedback_records": "Visszajelzési rekordok frissítése...",
|
||||
"request_feedback_source": "Forrásintegráció kérése",
|
||||
"required": "Kötelező",
|
||||
"save_changes": "Változtatások mentése",
|
||||
"search_feedback": "Visszajelzések keresése",
|
||||
"select_a_survey_to_see_questions": "Válassz egy kérdőívet a kérdések megtekintéséhez",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "érték beállítása",
|
||||
"setup_connection": "Kapcsolat beállítása",
|
||||
"showing_count_loaded": "{count} rekord megjelenítése",
|
||||
"showing_rows": "3 megjelenítve {count} sorból",
|
||||
"showing_rows": "{visible} sor megjelenítve a(z) {total} sorból",
|
||||
"source": "forrás",
|
||||
"source_connect_csv_description": "Visszajelzések importálása CSV fájlokból",
|
||||
"source_connect_feedback_record_mcp_description": "Visszajelzési rekordok küldése az MCP integráción keresztül.",
|
||||
"source_connect_formbricks_description": "Visszajelzések csatlakoztatása a Formbricks kérdőívekből",
|
||||
"source_fields": "Forrásmezők",
|
||||
"source_id": "Forrásazonosító",
|
||||
"source_name": "Forrásnév",
|
||||
"source_type": "Forrás típus",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "管理APIを使用してフィードバックレコードを送信します。",
|
||||
"auto_generated": "自動生成",
|
||||
"change_file": "ファイルを変更",
|
||||
"click_load_sample_csv": "「サンプルCSVを読み込む」をクリックして列を表示",
|
||||
"clear_mapping": "マッピングをクリア",
|
||||
"click_to_upload": "クリックしてアップロード",
|
||||
"collected_at": "収集日時",
|
||||
"configure_import": "インポートを設定",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "コネクタが正常に作成されました",
|
||||
"connector_deleted_successfully": "コネクタが正常に削除されました",
|
||||
"connector_duplicated_successfully": "コネクタが正常に複製されました",
|
||||
"connector_name": "コネクタ名",
|
||||
"connector_name_hint": "ダッシュボードに表示されるコネクタ名です。アップロードされたファイル名から自動入力されますが、いつでも編集できます。",
|
||||
"connector_status_updated_successfully": "コネクタのステータスが正常に更新されました",
|
||||
"connector_updated_successfully": "コネクタが正常に更新されました",
|
||||
"connectors": "コネクタ",
|
||||
"create_mapping": "マッピングを作成",
|
||||
"created_by": "作成者",
|
||||
"csv_advanced": "詳細設定",
|
||||
"csv_advanced_hint": "あまり使われないフィールドです。必要に応じて設定してください。",
|
||||
"csv_at_least_one_row": "CSVには少なくとも1行のデータが必要です。",
|
||||
"csv_auto_mapped": "自動マッピング済み",
|
||||
"csv_auto_mapped_tooltip": "Formbricksが「{column}」から自動的にマッピングしました。いつでも変更できます。",
|
||||
"csv_basic_required": "基本",
|
||||
"csv_basic_required_hint": "CSVの列を選択するか、すべての行に適用される固定値を設定してください。必須項目にはアスタリスクが付いています。",
|
||||
"csv_column_used_by": "マッピング先: {target}",
|
||||
"csv_columns": "CSV列",
|
||||
"csv_data_preview": "データプレビュー",
|
||||
"csv_empty_column_headers": "CSVに空の列ヘッダーが含まれています。すべての列に名前が必要です。",
|
||||
"csv_file_too_large": "CSVファイルが大きすぎます。最大サイズは2MBです。",
|
||||
"csv_files_only": "CSVファイルのみ",
|
||||
"csv_first_value": "例: {value}",
|
||||
"csv_fixed_value_action": "固定値を設定…",
|
||||
"csv_fixed_value_label": "固定値: {value}",
|
||||
"csv_import": "CSVインポート",
|
||||
"csv_import_complete": "CSVインポート完了: {successes}件成功、{failures}件失敗、{skipped}件スキップ",
|
||||
"csv_import_duplicate_warning": "データを2回インポートすると、重複したレコードが作成されます。",
|
||||
"csv_import_duplicate_warning": "このインポートでは保存されたCSVマッピングを使用します。繰り返しインポートを同じ送信元に照合できるよう、このファイルに同じ送信IDの列が含まれていることを確認してください。",
|
||||
"csv_inconsistent_columns": "行 {row} の列が一致しません。すべての行は同じヘッダーを持つ必要があります。",
|
||||
"csv_max_records": "最大 {max} 件のレコードまで許可されています。",
|
||||
"csv_now_label": "現在(インポート時刻を使用)",
|
||||
"csv_pick_column_placeholder": "列を選択するか値を設定…",
|
||||
"csv_required_fields_missing": "保存する前に必須フィールドをマッピングしてください: {fields}",
|
||||
"csv_response_preview": "サンプル: 「{sample}」→ {target} として保存されます。",
|
||||
"csv_rows_count": "{count, plural, other {# 行}}",
|
||||
"csv_sample_label": "CSVサンプル",
|
||||
"csv_saved_mapping_missing_columns": "このファイルには、フィードバックソースで必要な列が不足しています。設定された形式と互換性のあるCSVをアップロードしてください。",
|
||||
"csv_source_context": "ソースコンテキスト",
|
||||
"csv_source_context_hint": "このフィードバックのバッチがどこから来たかを識別します。",
|
||||
"csv_unmapped_columns": "未マッピングの列({count}件): {columns}",
|
||||
"csv_unmapped_columns_explainer": "これらの列はフィードバックレコードのどのフィールドにも使用されていません。インポート時に無視されます。",
|
||||
"custom_source_type": "カスタムソースタイプ",
|
||||
"custom_source_type_placeholder": "カスタムソースタイプを入力してください",
|
||||
"default_connector_name_csv": "CSVインポート",
|
||||
"default_connector_name_formbricks": "Formbricks フォーム接続",
|
||||
"delete_feedback_record": "フィードバック記録を削除",
|
||||
"delete_feedback_record_confirmation": "この操作により、フィードバック記録が完全に削除され、接続されているディレクトリから削除されます。",
|
||||
"delete_feedback_records_confirmation": "この操作により、{count}件のフィードバック記録が完全に削除され、接続されているディレクトリから削除されます。",
|
||||
"discard_feedback_record_changes_description": "このドロワーを閉じると、変更内容は失われます。",
|
||||
"discard_feedback_record_changes_title": "保存されていない変更を破棄しますか?",
|
||||
"drop_a_field_here": "ここにフィールドをドロップ",
|
||||
"drop_field_or": "フィールドをドロップまたは",
|
||||
"edit_csv_mapping": "CSVマッピングを編集",
|
||||
"edit_source_connection": "ソース接続を編集",
|
||||
"enter_name_for_source": "このソースの名前を入力",
|
||||
"enter_value": "値を入力...",
|
||||
"enum": "列挙型",
|
||||
"error_connector_field_mapping_duplicate": "このソースのフィールドマッピングが重複しています",
|
||||
"error_connector_formbricks_mapping_duplicate": "このソースの質問マッピングが重複しています",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "ソース名は必須です",
|
||||
"error_connector_questions_required": "少なくとも1つの質問を選択してください",
|
||||
"error_connector_survey_required": "アンケートを選択してください",
|
||||
"failed_to_delete_feedback_records": "フィードバックレコードの削除に失敗しました",
|
||||
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
|
||||
"feedback_date": "現在の日付",
|
||||
"feedback_directory": "フィードバックディレクトリ",
|
||||
"feedback_record_created_successfully": "フィードバックレコードが正常に作成されました",
|
||||
"feedback_record_deleted_successfully": "フィードバック記録を削除しました",
|
||||
"feedback_record_details": "フィードバック記録の詳細",
|
||||
"feedback_record_details_description": "フィードバック レコード フィールドを確認して更新します。",
|
||||
"feedback_record_fields": "フィードバックレコードフィールド",
|
||||
"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}件のフィードバックレコードを削除しました",
|
||||
"feedback_records_refreshed": "フィードバックレコードを更新しました",
|
||||
"feedback_sources": "フィードバックソース",
|
||||
"feedback_sources_directory_access_multiple": "これらのソースからの新しいレコードは次の場所に保存されます:{directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "過去の回答をインポート",
|
||||
"import_historical_responses_description": "このアンケートから既存の回答を今すぐインポートします。",
|
||||
"import_rows": "{count}行をインポート",
|
||||
"import_via_source_name": "「{sourceName}」経由でインポート",
|
||||
"import_via_source_name": "「{sourceName}」としてインポート",
|
||||
"importing_data": "データをインポート中...",
|
||||
"importing_historical_data": "過去のデータをインポート中...",
|
||||
"invalid_enum_values": "{field}にマッピングされた列に無効な値があります",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "フィードバックディレクトリが未リンク",
|
||||
"no_feedback_records": "フィードバックレコードはまだありません。コネクタがデータの送信を開始すると、ここにレコードが表示されます。",
|
||||
"no_formbricks_surveys_available_description": "このワークスペースにはまだフォームがありません。フィードバックソースとして使用するには<surveyLink>新しいフォームを作成</surveyLink>してください。",
|
||||
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
|
||||
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
|
||||
"optional": "任意",
|
||||
"or_drag_and_drop": "またはドラッグ&ドロップ",
|
||||
"question_type_not_supported": "この質問タイプはサポートされていません",
|
||||
"refresh_feedback_records": "フィードバック記録を更新",
|
||||
"refreshing_feedback_records": "フィードバックレコードを更新中...",
|
||||
"request_feedback_source": "ソース統合をリクエスト",
|
||||
"required": "必須",
|
||||
"save_changes": "変更を保存",
|
||||
"search_feedback": "フィードバックを検索",
|
||||
"select_a_survey_to_see_questions": "フォームを選択して質問を表示",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "値を設定",
|
||||
"setup_connection": "接続を設定",
|
||||
"showing_count_loaded": "{count}件のレコードを表示中",
|
||||
"showing_rows": "{count}行中3行を表示",
|
||||
"showing_rows": "{total}行中{visible}行を表示",
|
||||
"source": "ソース",
|
||||
"source_connect_csv_description": "CSVファイルからフィードバックをインポート",
|
||||
"source_connect_feedback_record_mcp_description": "MCP統合を通じてフィードバックレコードを送信します。",
|
||||
"source_connect_formbricks_description": "Formbricksフォームからフィードバックを接続",
|
||||
"source_fields": "ソースフィールド",
|
||||
"source_id": "ソースID",
|
||||
"source_name": "ソース名",
|
||||
"source_type": "ソースタイプ",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Verstuur feedbackrecords via de Management API.",
|
||||
"auto_generated": "Automatisch gegenereerd",
|
||||
"change_file": "Bestand wijzigen",
|
||||
"click_load_sample_csv": "Klik op 'Voorbeeld CSV laden' om kolommen te zien",
|
||||
"clear_mapping": "Mapping wissen",
|
||||
"click_to_upload": "Klik om te uploaden",
|
||||
"collected_at": "Verzameld op",
|
||||
"configure_import": "Import configureren",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Connector succesvol aangemaakt",
|
||||
"connector_deleted_successfully": "Connector succesvol verwijderd",
|
||||
"connector_duplicated_successfully": "Connector succesvol gedupliceerd",
|
||||
"connector_name": "Connectornaam",
|
||||
"connector_name_hint": "Zo verschijnt deze connector in je dashboard. Wordt automatisch ingevuld op basis van de geüploade bestandsnaam — pas aan wanneer je wilt.",
|
||||
"connector_status_updated_successfully": "Connectorstatus succesvol bijgewerkt",
|
||||
"connector_updated_successfully": "Connector succesvol bijgewerkt",
|
||||
"connectors": "Connectoren",
|
||||
"create_mapping": "Koppeling aanmaken",
|
||||
"created_by": "Gemaakt door",
|
||||
"csv_advanced": "Geavanceerd",
|
||||
"csv_advanced_hint": "Minder gebruikte velden. Stel ze in wanneer relevant.",
|
||||
"csv_at_least_one_row": "CSV moet minimaal één datarij bevatten.",
|
||||
"csv_auto_mapped": "Automatisch gekoppeld",
|
||||
"csv_auto_mapped_tooltip": "Formbricks heeft dit automatisch gekoppeld vanuit \"{column}\". Je kunt dit altijd aanpassen.",
|
||||
"csv_basic_required": "Basis",
|
||||
"csv_basic_required_hint": "Kies een CSV-kolom of stel een vaste waarde in die op elke rij wordt toegepast. Verplichte velden zijn gemarkeerd met een sterretje.",
|
||||
"csv_column_used_by": "Gekoppeld aan: {target}",
|
||||
"csv_columns": "CSV kolommen",
|
||||
"csv_data_preview": "Gegevensvoorbeeld",
|
||||
"csv_empty_column_headers": "CSV bevat lege kolomkoppen. Alle kolommen moeten een naam hebben.",
|
||||
"csv_file_too_large": "CSV-bestand is te groot. Maximale grootte is 2MB.",
|
||||
"csv_files_only": "Alleen CSV bestanden",
|
||||
"csv_first_value": "Voorbeeld: {value}",
|
||||
"csv_fixed_value_action": "Stel een vaste waarde in…",
|
||||
"csv_fixed_value_label": "Vaste waarde: {value}",
|
||||
"csv_import": "CSV import",
|
||||
"csv_import_complete": "CSV-import voltooid: {successes} geslaagd, {failures} mislukt, {skipped} overgeslagen",
|
||||
"csv_import_duplicate_warning": "Gegevens twee keer importeren zal dubbele records aanmaken.",
|
||||
"csv_import_duplicate_warning": "Deze import gebruikt je opgeslagen CSV-mapping. Zorg ervoor dat dit bestand dezelfde kolom voor inzending-ID bevat, zodat herhaalde imports aan dezelfde broninzending kunnen worden gekoppeld.",
|
||||
"csv_inconsistent_columns": "Rij {row} heeft inconsistente kolommen. Alle rijen moeten dezelfde headers hebben.",
|
||||
"csv_max_records": "Maximaal {max} records toegestaan.",
|
||||
"csv_now_label": "Nu (gebruik het importtijdstip)",
|
||||
"csv_pick_column_placeholder": "Kies een kolom of stel een waarde in…",
|
||||
"csv_required_fields_missing": "Koppel de verplichte velden voordat je opslaat: {fields}",
|
||||
"csv_response_preview": "Voorbeeld: \"{sample}\" → opgeslagen als {target}.",
|
||||
"csv_rows_count": "{count, plural, one {# rij} other {# rijen}}",
|
||||
"csv_sample_label": "Voorbeeld-CSV",
|
||||
"csv_saved_mapping_missing_columns": "Dit bestand mist kolommen die nodig zijn voor de feedbackbron. Upload een CSV die compatibel is met het geconfigureerde formaat.",
|
||||
"csv_source_context": "Broncontext",
|
||||
"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",
|
||||
"default_connector_name_csv": "CSV import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey verbinding",
|
||||
"delete_feedback_record": "Feedbackrecord verwijderen",
|
||||
"delete_feedback_record_confirmation": "Hiermee wordt het feedbackrecord permanent verwijderd en uit de gekoppelde map gehaald.",
|
||||
"delete_feedback_records_confirmation": "Hiermee worden {count} feedbackrecords permanent verwijderd en uit de gekoppelde map gehaald.",
|
||||
"discard_feedback_record_changes_description": "Als u deze lade sluit, gaan uw wijzigingen verloren.",
|
||||
"discard_feedback_record_changes_title": "Niet-opgeslagen wijzigingen verwijderen?",
|
||||
"drop_a_field_here": "Zet hier een veld neer",
|
||||
"drop_field_or": "Zet veld neer of",
|
||||
"edit_csv_mapping": "CSV-mapping bewerken",
|
||||
"edit_source_connection": "Bronverbinding bewerken",
|
||||
"enter_name_for_source": "Voer een naam in voor deze bron",
|
||||
"enter_value": "Voer waarde in...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Dubbele veldkoppeling voor deze bron",
|
||||
"error_connector_formbricks_mapping_duplicate": "Dubbele vraagkoppeling voor deze bron",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "Bronnaam is verplicht",
|
||||
"error_connector_questions_required": "Selecteer minimaal één vraag",
|
||||
"error_connector_survey_required": "Selecteer een enquête",
|
||||
"failed_to_delete_feedback_records": "Feedbackgegevens verwijderen mislukt",
|
||||
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
|
||||
"feedback_date": "Huidige datum",
|
||||
"feedback_directory": "Feedbackmap",
|
||||
"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_fields": "Feedbackrecordvelden",
|
||||
"feedback_record_mcp": "Feedbackrecord MCP",
|
||||
"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",
|
||||
"feedback_records_refreshed": "Feedbackrecords vernieuwd",
|
||||
"feedback_sources": "Feedbackbronnen",
|
||||
"feedback_sources_directory_access_multiple": "Nieuwe records van deze bronnen worden opgeslagen in: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Historische reacties importeren",
|
||||
"import_historical_responses_description": "Importeer bestaande reacties van deze enquête nu.",
|
||||
"import_rows": "{count, plural, one {Importeer 1 rij} other {Importeer # rijen}}",
|
||||
"import_via_source_name": "Importeren via \"{sourceName}\"",
|
||||
"import_via_source_name": "Importeren als \"{sourceName}\"",
|
||||
"importing_data": "Gegevens importeren...",
|
||||
"importing_historical_data": "Historische gegevens importeren...",
|
||||
"invalid_enum_values": "Ongeldige waarden in kolom gekoppeld aan {field}",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "Geen feedbackmap gekoppeld",
|
||||
"no_feedback_records": "Nog geen feedbackrecords. Records verschijnen hier zodra je connectoren gegevens beginnen te verzenden.",
|
||||
"no_formbricks_surveys_available_description": "Er zijn nog geen enquêtes in deze werkruimte. <surveyLink>Maak een nieuwe enquête</surveyLink> om er een als feedbackbron te gebruiken.",
|
||||
"no_source_fields_loaded": "Nog geen bronvelden geladen",
|
||||
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
|
||||
"optional": "Optioneel",
|
||||
"or_drag_and_drop": "of sleep en zet neer",
|
||||
"question_type_not_supported": "Dit vraagtype wordt niet ondersteund",
|
||||
"refresh_feedback_records": "Feedbackrecords verversen",
|
||||
"refreshing_feedback_records": "Feedbackrecords vernieuwen...",
|
||||
"request_feedback_source": "Bronintegratie aanvragen",
|
||||
"required": "Vereist",
|
||||
"save_changes": "Wijzigingen opslaan",
|
||||
"search_feedback": "Zoek feedback",
|
||||
"select_a_survey_to_see_questions": "Selecteer een enquête om de vragen te zien",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "waarde instellen",
|
||||
"setup_connection": "Verbinding instellen",
|
||||
"showing_count_loaded": "Er worden {count} records weergegeven",
|
||||
"showing_rows": "3 van {count} rijen weergegeven",
|
||||
"showing_rows": "{visible} van {total} rijen weergegeven",
|
||||
"source": "bron",
|
||||
"source_connect_csv_description": "Importeer feedback uit CSV-bestanden",
|
||||
"source_connect_feedback_record_mcp_description": "Verstuur feedbackrecords via de MCP-integratie.",
|
||||
"source_connect_formbricks_description": "Verbind feedback van je Formbricks-enquêtes",
|
||||
"source_fields": "Bronvelden",
|
||||
"source_id": "Bron-ID",
|
||||
"source_name": "Bronnaam",
|
||||
"source_type": "Brontype",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Envie registros de feedback usando a API de Gerenciamento.",
|
||||
"auto_generated": "Gerado automaticamente",
|
||||
"change_file": "Alterar arquivo",
|
||||
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
|
||||
"clear_mapping": "Limpar mapeamento",
|
||||
"click_to_upload": "Clique para fazer upload",
|
||||
"collected_at": "Coletado em",
|
||||
"configure_import": "Configurar importação",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Conector criado com sucesso",
|
||||
"connector_deleted_successfully": "Conector excluído com sucesso",
|
||||
"connector_duplicated_successfully": "Conector duplicado com sucesso",
|
||||
"connector_name": "Nome do Conector",
|
||||
"connector_name_hint": "Como esse conector aparece no seu painel. Preenchido automaticamente a partir do nome do arquivo enviado — edite quando quiser.",
|
||||
"connector_status_updated_successfully": "Status do conector atualizado com sucesso",
|
||||
"connector_updated_successfully": "Conector atualizado com sucesso",
|
||||
"connectors": "Conectores",
|
||||
"create_mapping": "Criar mapeamento",
|
||||
"created_by": "Criado por",
|
||||
"csv_advanced": "Avançado",
|
||||
"csv_advanced_hint": "Campos menos comuns. Configure-os quando relevante.",
|
||||
"csv_at_least_one_row": "O CSV deve conter pelo menos uma linha de dados.",
|
||||
"csv_auto_mapped": "Mapeado automaticamente",
|
||||
"csv_auto_mapped_tooltip": "O Formbricks mapeou isso automaticamente de \"{column}\". Você pode alterar a qualquer momento.",
|
||||
"csv_basic_required": "Básico",
|
||||
"csv_basic_required_hint": "Escolha uma coluna do CSV ou defina um valor fixo aplicado a todas as linhas. Os campos obrigatórios estão marcados com um asterisco.",
|
||||
"csv_column_used_by": "Mapeado para: {target}",
|
||||
"csv_columns": "Colunas CSV",
|
||||
"csv_data_preview": "Prévia dos dados",
|
||||
"csv_empty_column_headers": "O CSV contém cabeçalhos de coluna vazios. Todas as colunas devem ter um nome.",
|
||||
"csv_file_too_large": "O arquivo CSV é muito grande. O tamanho máximo é 2MB.",
|
||||
"csv_files_only": "Apenas arquivos CSV",
|
||||
"csv_first_value": "Exemplo: {value}",
|
||||
"csv_fixed_value_action": "Definir um valor fixo…",
|
||||
"csv_fixed_value_label": "Valor fixo: {value}",
|
||||
"csv_import": "Importação CSV",
|
||||
"csv_import_complete": "Importação de CSV concluída: {successes} bem-sucedidas, {failures} falharam, {skipped} ignoradas",
|
||||
"csv_import_duplicate_warning": "Importar dados duas vezes criará registros duplicados.",
|
||||
"csv_import_duplicate_warning": "Esta importação usa seu mapeamento de CSV salvo. Certifique-se de que este arquivo inclui a mesma coluna de ID de Envio para que importações repetidas possam ser correspondidas ao mesmo envio de origem.",
|
||||
"csv_inconsistent_columns": "A linha {row} possui colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.",
|
||||
"csv_max_records": "Máximo de {max} registros permitidos.",
|
||||
"csv_now_label": "Agora (usar o horário da importação)",
|
||||
"csv_pick_column_placeholder": "Escolha uma coluna ou defina um valor…",
|
||||
"csv_required_fields_missing": "Por favor, mapeie os campos obrigatórios antes de salvar: {fields}",
|
||||
"csv_response_preview": "Exemplo: \"{sample}\" → armazenado como {target}.",
|
||||
"csv_rows_count": "{count, plural, one {# linha} other {# linhas}}",
|
||||
"csv_sample_label": "Exemplo de CSV",
|
||||
"csv_saved_mapping_missing_columns": "Este arquivo não possui colunas necessárias para a Fonte de Feedback. Por favor, envie um CSV compatível com o formato configurado.",
|
||||
"csv_source_context": "Contexto de Origem",
|
||||
"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",
|
||||
"default_connector_name_csv": "Importação CSV",
|
||||
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
|
||||
"delete_feedback_record": "Excluir registro de feedback",
|
||||
"delete_feedback_record_confirmation": "Isso excluirá permanentemente o registro de feedback e o removerá do diretório conectado.",
|
||||
"delete_feedback_records_confirmation": "Isso excluirá permanentemente {count} registros de feedback e os removerá do diretório conectado.",
|
||||
"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?",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"edit_csv_mapping": "Editar mapeamento CSV",
|
||||
"edit_source_connection": "Editar conexão de origem",
|
||||
"enter_name_for_source": "Digite um nome para esta origem",
|
||||
"enter_value": "Digite o valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "O nome da fonte é obrigatório",
|
||||
"error_connector_questions_required": "Selecione pelo menos uma pergunta",
|
||||
"error_connector_survey_required": "Selecione uma pesquisa",
|
||||
"failed_to_delete_feedback_records": "Falha ao excluir registros de feedback",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_directory": "Diretório 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_fields": "Campos do registro de feedback",
|
||||
"feedback_record_mcp": "Registro de Feedback 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",
|
||||
"feedback_records_refreshed": "Registros de feedback atualizados",
|
||||
"feedback_sources": "Fontes de Feedback",
|
||||
"feedback_sources_directory_access_multiple": "Novos registros dessas fontes serão armazenados em: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Importar respostas históricas",
|
||||
"import_historical_responses_description": "Importe respostas existentes desta pesquisa agora.",
|
||||
"import_rows": "Importar {count} linhas",
|
||||
"import_via_source_name": "Importar via \"{sourceName}\"",
|
||||
"import_via_source_name": "Importar como \"{sourceName}\"",
|
||||
"importing_data": "Importando dados...",
|
||||
"importing_historical_data": "Importando dados históricos...",
|
||||
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
|
||||
"no_feedback_records": "Nenhum registro de feedback ainda. Os registros aparecerão aqui assim que seus conectores começarem a enviar dados.",
|
||||
"no_formbricks_surveys_available_description": "Ainda não há pesquisas neste workspace. <surveyLink>Crie uma nova pesquisa</surveyLink> para usar uma como fonte de feedback.",
|
||||
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
|
||||
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
|
||||
"optional": "Opcional",
|
||||
"or_drag_and_drop": "ou arraste e solte",
|
||||
"question_type_not_supported": "Este tipo de pergunta não é suportado",
|
||||
"refresh_feedback_records": "Atualizar registros de feedback",
|
||||
"refreshing_feedback_records": "Atualizando registros de feedback...",
|
||||
"request_feedback_source": "Solicitar integração de fonte",
|
||||
"required": "Obrigatório",
|
||||
"save_changes": "Salvar alterações",
|
||||
"search_feedback": "Buscar feedback",
|
||||
"select_a_survey_to_see_questions": "Selecione uma pesquisa para ver suas perguntas",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "definir valor",
|
||||
"setup_connection": "Configurar conexão",
|
||||
"showing_count_loaded": "Mostrando {count} registros",
|
||||
"showing_rows": "Mostrando 3 de {count} linhas",
|
||||
"showing_rows": "Mostrando {visible} de {total} linhas",
|
||||
"source": "fonte",
|
||||
"source_connect_csv_description": "Importar feedback de arquivos CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envie registros de feedback através da integração MCP.",
|
||||
"source_connect_formbricks_description": "Conectar feedback das suas pesquisas Formbricks",
|
||||
"source_fields": "Campos de origem",
|
||||
"source_id": "ID da fonte",
|
||||
"source_name": "Nome da origem",
|
||||
"source_type": "Tipo de fonte",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Envia registos de feedback através da API de gestão.",
|
||||
"auto_generated": "Gerado automaticamente",
|
||||
"change_file": "Alterar ficheiro",
|
||||
"click_load_sample_csv": "Clique em 'Carregar CSV de exemplo' para ver as colunas",
|
||||
"clear_mapping": "Limpar mapeamento",
|
||||
"click_to_upload": "Clique para carregar",
|
||||
"collected_at": "Recolhido em",
|
||||
"configure_import": "Configurar importação",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Conector criado com sucesso",
|
||||
"connector_deleted_successfully": "Conector eliminado com sucesso",
|
||||
"connector_duplicated_successfully": "Conector duplicado com sucesso",
|
||||
"connector_name": "Nome do Conector",
|
||||
"connector_name_hint": "Como este conector aparece no teu painel. Preenchido automaticamente a partir do nome do ficheiro carregado — podes editar a qualquer momento.",
|
||||
"connector_status_updated_successfully": "Estado do conector atualizado com sucesso",
|
||||
"connector_updated_successfully": "Conector atualizado com sucesso",
|
||||
"connectors": "Conectores",
|
||||
"create_mapping": "Criar mapeamento",
|
||||
"created_by": "Criado por",
|
||||
"csv_advanced": "Avançado",
|
||||
"csv_advanced_hint": "Campos menos comuns. Define-os quando forem relevantes.",
|
||||
"csv_at_least_one_row": "O CSV deve conter pelo menos uma linha de dados.",
|
||||
"csv_auto_mapped": "Mapeado automaticamente",
|
||||
"csv_auto_mapped_tooltip": "O Formbricks mapeou isto automaticamente a partir de \"{column}\". Podes alterar a qualquer momento.",
|
||||
"csv_basic_required": "Básico",
|
||||
"csv_basic_required_hint": "Escolhe uma coluna CSV ou define um valor fixo aplicado a todas as linhas. Os campos obrigatórios estão marcados com um asterisco.",
|
||||
"csv_column_used_by": "Mapeado para: {target}",
|
||||
"csv_columns": "Colunas CSV",
|
||||
"csv_data_preview": "Pré-visualização de dados",
|
||||
"csv_empty_column_headers": "O CSV contém cabeçalhos de coluna vazios. Todas as colunas devem ter um nome.",
|
||||
"csv_file_too_large": "O ficheiro CSV é demasiado grande. O tamanho máximo é 2MB.",
|
||||
"csv_files_only": "Apenas ficheiros CSV",
|
||||
"csv_first_value": "Exemplo: {value}",
|
||||
"csv_fixed_value_action": "Definir um valor fixo…",
|
||||
"csv_fixed_value_label": "Valor fixo: {value}",
|
||||
"csv_import": "Importação CSV",
|
||||
"csv_import_complete": "Importação de CSV concluída: {successes} com sucesso, {failures} falharam, {skipped} ignorados",
|
||||
"csv_import_duplicate_warning": "Importar dados duas vezes irá criar registos duplicados.",
|
||||
"csv_import_duplicate_warning": "Esta importação utiliza o teu mapeamento CSV guardado. Certifica-te de que este ficheiro inclui a mesma coluna de ID de Submissão para que importações repetidas possam ser associadas à mesma submissão de origem.",
|
||||
"csv_inconsistent_columns": "A linha {row} tem colunas inconsistentes. Todas as linhas devem ter os mesmos cabeçalhos.",
|
||||
"csv_max_records": "Máximo de {max} registos permitidos.",
|
||||
"csv_now_label": "Agora (usar a hora da importação)",
|
||||
"csv_pick_column_placeholder": "Escolhe uma coluna ou define um valor…",
|
||||
"csv_required_fields_missing": "Por favor, mapeia os campos obrigatórios antes de guardar: {fields}",
|
||||
"csv_response_preview": "Exemplo: \"{sample}\" → armazenado como {target}.",
|
||||
"csv_rows_count": "{count, plural, one {# linha} other {# linhas}}",
|
||||
"csv_sample_label": "Exemplo de CSV",
|
||||
"csv_saved_mapping_missing_columns": "Este ficheiro não contém as colunas necessárias para a Fonte de Feedback. Por favor, carrega um CSV compatível com o formato configurado.",
|
||||
"csv_source_context": "Contexto da Origem",
|
||||
"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",
|
||||
"default_connector_name_csv": "Importação CSV",
|
||||
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
|
||||
"delete_feedback_record": "Eliminar registo de feedback",
|
||||
"delete_feedback_record_confirmation": "Esta ação irá eliminar permanentemente o registo de feedback e removê-lo do diretório associado.",
|
||||
"delete_feedback_records_confirmation": "Esta ação irá eliminar permanentemente {count} registos de feedback e removê-los do diretório associado.",
|
||||
"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?",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
"drop_field_or": "Solte o campo ou",
|
||||
"edit_csv_mapping": "Editar mapeamento CSV",
|
||||
"edit_source_connection": "Editar ligação de origem",
|
||||
"enter_name_for_source": "Introduz um nome para esta origem",
|
||||
"enter_value": "Introduzir valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "O nome da origem é obrigatório",
|
||||
"error_connector_questions_required": "Seleciona pelo menos uma pergunta",
|
||||
"error_connector_survey_required": "Seleciona um inquérito",
|
||||
"failed_to_delete_feedback_records": "Falha ao eliminar registos de feedback",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_directory": "Diretório 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_fields": "Campos de registo de feedback",
|
||||
"feedback_record_mcp": "MCP de Registo de Feedback",
|
||||
"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",
|
||||
"feedback_records_refreshed": "Registos de feedback atualizados",
|
||||
"feedback_sources": "Fontes de Feedback",
|
||||
"feedback_sources_directory_access_multiple": "Novos registos destas fontes serão armazenados em: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Importar respostas históricas",
|
||||
"import_historical_responses_description": "Importa agora as respostas existentes deste inquérito.",
|
||||
"import_rows": "Importar {count} linhas",
|
||||
"import_via_source_name": "Importar via \"{sourceName}\"",
|
||||
"import_via_source_name": "Importar como \"{sourceName}\"",
|
||||
"importing_data": "A importar dados...",
|
||||
"importing_historical_data": "A importar dados históricos...",
|
||||
"invalid_enum_values": "Valores inválidos na coluna mapeada para {field}",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
|
||||
"no_feedback_records": "Ainda não há registos de feedback. Os registos aparecerão aqui assim que os teus conectores começarem a enviar dados.",
|
||||
"no_formbricks_surveys_available_description": "Ainda não há inquéritos neste workspace. <surveyLink>Cria um novo inquérito</surveyLink> para usar um como fonte de feedback.",
|
||||
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
|
||||
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
|
||||
"optional": "Opcional",
|
||||
"or_drag_and_drop": "ou arraste e largue",
|
||||
"question_type_not_supported": "Este tipo de pergunta não é suportado",
|
||||
"refresh_feedback_records": "Atualizar registos de feedback",
|
||||
"refreshing_feedback_records": "A atualizar registos de feedback...",
|
||||
"request_feedback_source": "Solicitar integração de fonte",
|
||||
"required": "Obrigatório",
|
||||
"save_changes": "Guardar alterações",
|
||||
"search_feedback": "Pesquisar feedback",
|
||||
"select_a_survey_to_see_questions": "Selecione um inquérito para ver as suas perguntas",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "definir valor",
|
||||
"setup_connection": "Configurar ligação",
|
||||
"showing_count_loaded": "A mostrar {count} registos",
|
||||
"showing_rows": "A mostrar 3 de {count} linhas",
|
||||
"showing_rows": "A mostrar {visible} de {total} linhas",
|
||||
"source": "fonte",
|
||||
"source_connect_csv_description": "Importar feedback de ficheiros CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Envia registos de feedback através da integração MCP.",
|
||||
"source_connect_formbricks_description": "Conectar feedback dos seus inquéritos Formbricks",
|
||||
"source_fields": "Campos da fonte",
|
||||
"source_id": "ID da fonte",
|
||||
"source_name": "Nome da fonte",
|
||||
"source_type": "Tipo de fonte",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Trimite înregistrări de feedback folosind API-ul de management.",
|
||||
"auto_generated": "Generat automat",
|
||||
"change_file": "Schimbă fișierul",
|
||||
"click_load_sample_csv": "Apasă pe „Încarcă CSV de exemplu” pentru a vedea coloanele",
|
||||
"clear_mapping": "Șterge maparea",
|
||||
"click_to_upload": "Apasă pentru a încărca",
|
||||
"collected_at": "Colectat la",
|
||||
"configure_import": "Configurează importul",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Conector creat cu succes",
|
||||
"connector_deleted_successfully": "Conector șters cu succes",
|
||||
"connector_duplicated_successfully": "Conector duplicat cu succes",
|
||||
"connector_name": "Numele conectorului",
|
||||
"connector_name_hint": "Cum apare acest conector în tabloul tău de bord. Completat automat din numele fișierului încărcat — poți edita oricând.",
|
||||
"connector_status_updated_successfully": "Statusul conectorului a fost actualizat cu succes",
|
||||
"connector_updated_successfully": "Conector actualizat cu succes",
|
||||
"connectors": "Conectori",
|
||||
"create_mapping": "Creează mapare",
|
||||
"created_by": "Creat de",
|
||||
"csv_advanced": "Avansat",
|
||||
"csv_advanced_hint": "Câmpuri mai puțin comune. Setează-le când sunt relevante.",
|
||||
"csv_at_least_one_row": "CSV-ul trebuie să conțină cel puțin un rând de date.",
|
||||
"csv_auto_mapped": "Mapat automat",
|
||||
"csv_auto_mapped_tooltip": "Formbricks a mapat automat din \"{column}\". Poți schimba oricând.",
|
||||
"csv_basic_required": "De bază",
|
||||
"csv_basic_required_hint": "Alege o coloană CSV sau setează o valoare fixă aplicată fiecărui rând. Câmpurile obligatorii sunt marcate cu un asterisc.",
|
||||
"csv_column_used_by": "Mapat la: {target}",
|
||||
"csv_columns": "Coloane CSV",
|
||||
"csv_data_preview": "Previzualizare date",
|
||||
"csv_empty_column_headers": "CSV-ul conține antete de coloană goale. Toate coloanele trebuie să aibă un nume.",
|
||||
"csv_file_too_large": "Fișierul CSV este prea mare. Dimensiunea maximă este de 2 MB.",
|
||||
"csv_files_only": "Doar fișiere CSV",
|
||||
"csv_first_value": "Exemplu: {value}",
|
||||
"csv_fixed_value_action": "Setează o valoare fixă…",
|
||||
"csv_fixed_value_label": "Valoare fixă: {value}",
|
||||
"csv_import": "Import CSV",
|
||||
"csv_import_complete": "Import CSV finalizat: {successes} reușite, {failures} eșuate, {skipped} omise",
|
||||
"csv_import_duplicate_warning": "Importarea datelor de două ori va crea înregistrări duplicate.",
|
||||
"csv_import_duplicate_warning": "Acest import folosește maparea CSV salvată. Asigură-te că fișierul include aceeași coloană cu ID-ul de trimitere, astfel încât importurile repetate să poată fi asociate cu aceeași trimitere sursă.",
|
||||
"csv_inconsistent_columns": "Rândul {row} are coloane inconsistente. Toate rândurile trebuie să aibă aceleași antete.",
|
||||
"csv_max_records": "Sunt permise maximum {max} înregistrări.",
|
||||
"csv_now_label": "Acum (folosește momentul importului)",
|
||||
"csv_pick_column_placeholder": "Alege o coloană sau setează o valoare…",
|
||||
"csv_required_fields_missing": "Te rugăm să mapezi câmpurile obligatorii înainte de salvare: {fields}",
|
||||
"csv_response_preview": "Exemplu: \"{sample}\" → stocat ca {target}.",
|
||||
"csv_rows_count": "{count, plural, one {# rând} few {# rânduri} other {# de rânduri}}",
|
||||
"csv_sample_label": "Exemplu CSV",
|
||||
"csv_saved_mapping_missing_columns": "Acest fișier nu conține coloanele necesare pentru Sursa de Feedback, te rugăm să încarci un CSV compatibil cu formatul configurat.",
|
||||
"csv_source_context": "Contextul sursei",
|
||||
"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",
|
||||
"default_connector_name_csv": "Import CSV",
|
||||
"default_connector_name_formbricks": "Conexiune chestionar Formbricks",
|
||||
"delete_feedback_record": "Șterge înregistrarea de feedback",
|
||||
"delete_feedback_record_confirmation": "Aceasta va șterge definitiv înregistrarea de feedback și o va elimina din directorul conectat.",
|
||||
"delete_feedback_records_confirmation": "Aceasta va șterge definitiv {count} înregistrări de feedback și le va elimina din directorul conectat.",
|
||||
"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?",
|
||||
"drop_a_field_here": "Trage un câmp aici",
|
||||
"drop_field_or": "Trage câmpul sau",
|
||||
"edit_csv_mapping": "Editează maparea CSV",
|
||||
"edit_source_connection": "Editează conexiunea sursei",
|
||||
"enter_name_for_source": "Introdu un nume pentru această sursă",
|
||||
"enter_value": "Introdu valoarea...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapare duplicată a câmpului pentru această sursă",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapare duplicată a întrebării pentru această sursă",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "Numele sursei este obligatoriu",
|
||||
"error_connector_questions_required": "Selectează cel puțin o întrebare",
|
||||
"error_connector_survey_required": "Selectează un sondaj",
|
||||
"failed_to_delete_feedback_records": "Eșec la ștergerea înregistrărilor de feedback",
|
||||
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
|
||||
"feedback_date": "Data curentă",
|
||||
"feedback_directory": "Director 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_fields": "Câmpuri înregistrare feedback",
|
||||
"feedback_record_mcp": "MCP Înregistrări Feedback",
|
||||
"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",
|
||||
"feedback_records_refreshed": "Înregistrările de feedback au fost actualizate",
|
||||
"feedback_sources": "Surse de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Înregistrările noi din aceste surse vor fi stocate în: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Importă răspunsuri istorice",
|
||||
"import_historical_responses_description": "Importă acum răspunsurile existente din acest sondaj.",
|
||||
"import_rows": "Importă {count, plural, one {# rând} few {# rânduri} other {# de rânduri}}",
|
||||
"import_via_source_name": "Import prin „{sourceName}”",
|
||||
"import_via_source_name": "Importă ca \"{sourceName}\"",
|
||||
"importing_data": "Se importă datele...",
|
||||
"importing_historical_data": "Se importă datele istorice...",
|
||||
"invalid_enum_values": "Valori invalide în coloana mapată la {field}",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "Niciun director de feedback conectat",
|
||||
"no_feedback_records": "Nu există încă înregistrări de feedback. Înregistrările vor apărea aici după ce conectorii tăi vor începe să trimită date.",
|
||||
"no_formbricks_surveys_available_description": "Nu există încă chestionare în acest spațiu de lucru. <surveyLink>Creează un chestionar nou</surveyLink> pentru a folosi unul ca sursă de feedback.",
|
||||
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
|
||||
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
|
||||
"optional": "Opțional",
|
||||
"or_drag_and_drop": "sau trage și lasă aici",
|
||||
"question_type_not_supported": "Acest tip de întrebare nu este suportat",
|
||||
"refresh_feedback_records": "Reîmprospătează înregistrările de feedback",
|
||||
"refreshing_feedback_records": "Se actualizează înregistrările de feedback...",
|
||||
"request_feedback_source": "Solicită integrarea sursei",
|
||||
"required": "Obligatoriu",
|
||||
"save_changes": "Salvează modificările",
|
||||
"search_feedback": "Caută feedback",
|
||||
"select_a_survey_to_see_questions": "Selectează un chestionar pentru a vedea întrebările",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "setează valoare",
|
||||
"setup_connection": "Configurează conexiunea",
|
||||
"showing_count_loaded": "Se afișează {count} înregistrări",
|
||||
"showing_rows": "Se afișează 3 din {count} rânduri",
|
||||
"showing_rows": "Se afișează {visible} din {total} rânduri",
|
||||
"source": "sursă",
|
||||
"source_connect_csv_description": "Importă feedback din fișiere CSV",
|
||||
"source_connect_feedback_record_mcp_description": "Trimite înregistrări de feedback prin integrarea MCP.",
|
||||
"source_connect_formbricks_description": "Conectează feedback din sondajele Formbricks",
|
||||
"source_fields": "Câmpuri sursă",
|
||||
"source_id": "ID sursă",
|
||||
"source_name": "Nume sursă",
|
||||
"source_type": "Tip sursă",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Отправляйте записи обратной связи через Management API.",
|
||||
"auto_generated": "Автоматически генерируется",
|
||||
"change_file": "Изменить файл",
|
||||
"click_load_sample_csv": "Нажмите «Загрузить пример CSV», чтобы увидеть столбцы",
|
||||
"clear_mapping": "Очистить сопоставление",
|
||||
"click_to_upload": "Кликните для загрузки",
|
||||
"collected_at": "Собрано",
|
||||
"configure_import": "Настроить импорт",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Коннектор успешно создан",
|
||||
"connector_deleted_successfully": "Коннектор успешно удалён",
|
||||
"connector_duplicated_successfully": "Коннектор успешно дублирован",
|
||||
"connector_name": "Название коннектора",
|
||||
"connector_name_hint": "Как этот коннектор отображается на твоей панели. Автоматически заполняется из имени загруженного файла — редактируй в любое время.",
|
||||
"connector_status_updated_successfully": "Статус коннектора успешно обновлён",
|
||||
"connector_updated_successfully": "Коннектор успешно обновлён",
|
||||
"connectors": "Коннекторы",
|
||||
"create_mapping": "Создать сопоставление",
|
||||
"created_by": "Создано пользователем",
|
||||
"csv_advanced": "Дополнительные",
|
||||
"csv_advanced_hint": "Менее распространённые поля. Заполняй их при необходимости.",
|
||||
"csv_at_least_one_row": "CSV должен содержать хотя бы одну строку с данными.",
|
||||
"csv_auto_mapped": "Автоматически сопоставлено",
|
||||
"csv_auto_mapped_tooltip": "Formbricks автоматически сопоставил это с \"{column}\". Вы можете изменить это в любое время.",
|
||||
"csv_basic_required": "Базовый",
|
||||
"csv_basic_required_hint": "Выберите столбец CSV или установите фиксированное значение, которое будет применяться к каждой строке. Обязательные поля отмечены звездочкой.",
|
||||
"csv_column_used_by": "Сопоставлено с: {target}",
|
||||
"csv_columns": "Столбцы CSV",
|
||||
"csv_data_preview": "Предпросмотр данных",
|
||||
"csv_empty_column_headers": "В CSV есть пустые заголовки столбцов. У всех столбцов должно быть имя.",
|
||||
"csv_file_too_large": "Файл CSV слишком большой. Максимальный размер — 2 МБ.",
|
||||
"csv_files_only": "Только файлы CSV",
|
||||
"csv_first_value": "Пример: {value}",
|
||||
"csv_fixed_value_action": "Задать фиксированное значение…",
|
||||
"csv_fixed_value_label": "Фиксированное значение: {value}",
|
||||
"csv_import": "Импорт CSV",
|
||||
"csv_import_complete": "Импорт CSV завершён: {successes} успешно, {failures} с ошибками, {skipped} пропущено",
|
||||
"csv_import_duplicate_warning": "Импорт уже загруженных данных может создать дубликаты записей.",
|
||||
"csv_import_duplicate_warning": "Этот импорт использует сохраненное сопоставление CSV. Убедитесь, что файл содержит тот же столбец с идентификатором отправки, чтобы повторные импорты можно было сопоставить с той же исходной записью.",
|
||||
"csv_inconsistent_columns": "В строке {row} несоответствие столбцов. Во всех строках должны быть одинаковые заголовки.",
|
||||
"csv_max_records": "Допустимо не более {max} записей.",
|
||||
"csv_now_label": "Сейчас (использовать время импорта)",
|
||||
"csv_pick_column_placeholder": "Выбери столбец или задай значение…",
|
||||
"csv_required_fields_missing": "Пожалуйста, сопоставь обязательные поля перед сохранением: {fields}",
|
||||
"csv_response_preview": "Пример: \"{sample}\" → сохранено как {target}.",
|
||||
"csv_rows_count": "{count, plural, one {# строка} few {# строки} many {# строк} other {# строк}}",
|
||||
"csv_sample_label": "Пример CSV",
|
||||
"csv_saved_mapping_missing_columns": "В этом файле отсутствуют столбцы, необходимые для источника обратной связи. Загрузи CSV-файл, совместимый с настроенным форматом.",
|
||||
"csv_source_context": "Исходный контекст",
|
||||
"csv_source_context_hint": "Указывает, откуда пришёл этот набор отзывов.",
|
||||
"csv_unmapped_columns": "Несопоставленные столбцы ({count}): {columns}",
|
||||
"csv_unmapped_columns_explainer": "Эти столбцы не используются ни одним полем записи отзывов. Они будут проигнорированы при импорте.",
|
||||
"custom_source_type": "Пользовательский тип источника",
|
||||
"custom_source_type_placeholder": "Введите собственный тип источника",
|
||||
"default_connector_name_csv": "Импорт CSV",
|
||||
"default_connector_name_formbricks": "Подключение опроса Formbricks",
|
||||
"delete_feedback_record": "Удалить запись обратной связи",
|
||||
"delete_feedback_record_confirmation": "Это навсегда удалит запись обратной связи и уберёт её из подключённого каталога.",
|
||||
"delete_feedback_records_confirmation": "Это навсегда удалит {count} {count, plural, one {запись обратной связи} few {записи обратной связи} many {записей обратной связи} other {записей обратной связи}} и уберёт их из подключённого каталога.",
|
||||
"discard_feedback_record_changes_description": "Ваши изменения будут потеряны, если вы закроете этот ящик.",
|
||||
"discard_feedback_record_changes_title": "Отменить несохраненные изменения?",
|
||||
"drop_a_field_here": "Перетащи сюда поле",
|
||||
"drop_field_or": "Перетащи поле или",
|
||||
"edit_csv_mapping": "Редактировать сопоставление CSV",
|
||||
"edit_source_connection": "Редактировать подключение источника",
|
||||
"enter_name_for_source": "Введи имя для этого источника",
|
||||
"enter_value": "Введите значение...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Дублирующееся сопоставление полей для этого источника",
|
||||
"error_connector_formbricks_mapping_duplicate": "Дублирующееся сопоставление вопросов для этого источника",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "Необходимо указать название источника",
|
||||
"error_connector_questions_required": "Выберите хотя бы один вопрос",
|
||||
"error_connector_survey_required": "Выберите опрос",
|
||||
"failed_to_delete_feedback_records": "Не удалось удалить записи обратной связи",
|
||||
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
|
||||
"feedback_date": "Текущая дата",
|
||||
"feedback_directory": "Директория обратной связи",
|
||||
"feedback_record_created_successfully": "Запись отзыва успешно создана",
|
||||
"feedback_record_deleted_successfully": "Запись обратной связи успешно удалена",
|
||||
"feedback_record_details": "Детали записи обратной связи",
|
||||
"feedback_record_details_description": "Просмотрите и обновите поля записи отзыва.",
|
||||
"feedback_record_fields": "Поля записи отзыва",
|
||||
"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} записей обратной связи",
|
||||
"feedback_records_refreshed": "Записи отзывов обновлены",
|
||||
"feedback_sources": "Источники обратной связи",
|
||||
"feedback_sources_directory_access_multiple": "Новые записи из этих источников будут сохранены в: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Импортировать предыдущие ответы",
|
||||
"import_historical_responses_description": "Импортируйте существующие ответы из этого опроса прямо сейчас.",
|
||||
"import_rows": "Импортировать {count, plural, one {# строку} few {# строки} many {# строк} other {# строки}}",
|
||||
"import_via_source_name": "Импорт через «{sourceName}»",
|
||||
"import_via_source_name": "Импортировать как \"{sourceName}\"",
|
||||
"importing_data": "Импорт данных...",
|
||||
"importing_historical_data": "Импорт исторических данных...",
|
||||
"invalid_enum_values": "Недопустимые значения в столбце, сопоставленном с {field}",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "Директория обратной связи не привязана",
|
||||
"no_feedback_records": "Пока нет записей отзывов. Они появятся здесь, когда коннекторы начнут отправлять данные.",
|
||||
"no_formbricks_surveys_available_description": "В этом рабочем пространстве пока нет опросов. <surveyLink>Создайте новый опрос</surveyLink>, чтобы использовать один как источник обратной связи.",
|
||||
"no_source_fields_loaded": "Поля источника ещё не загружены",
|
||||
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
|
||||
"optional": "Необязательно",
|
||||
"or_drag_and_drop": "или перетащите файл",
|
||||
"question_type_not_supported": "Этот тип вопроса не поддерживается",
|
||||
"refresh_feedback_records": "Обновить записи отзывов",
|
||||
"refreshing_feedback_records": "Обновляем записи отзывов...",
|
||||
"request_feedback_source": "Запросить интеграцию источника",
|
||||
"required": "Обязательно",
|
||||
"save_changes": "Сохранить изменения",
|
||||
"search_feedback": "Искать обратную связь",
|
||||
"select_a_survey_to_see_questions": "Выберите опрос, чтобы увидеть его вопросы",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "установить значение",
|
||||
"setup_connection": "Настроить подключение",
|
||||
"showing_count_loaded": "Показано записей: {count}",
|
||||
"showing_rows": "Показано 3 из {count} строк",
|
||||
"showing_rows": "Показано {visible} из {total} строк",
|
||||
"source": "источник",
|
||||
"source_connect_csv_description": "Импортировать отзывы из CSV-файлов",
|
||||
"source_connect_feedback_record_mcp_description": "Отправляйте записи обратной связи через интеграцию MCP.",
|
||||
"source_connect_formbricks_description": "Подключить отзывы из ваших опросов Formbricks",
|
||||
"source_fields": "Поля источника",
|
||||
"source_id": "Идентификатор источника",
|
||||
"source_name": "Имя источника",
|
||||
"source_type": "Тип источника",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "Automatiskt genererad",
|
||||
"change_file": "Byt fil",
|
||||
"click_load_sample_csv": "Klicka på 'Ladda exempel-CSV' för att se kolumner",
|
||||
"clear_mapping": "Rensa mappning",
|
||||
"click_to_upload": "Klicka för att ladda upp",
|
||||
"collected_at": "Insamlad",
|
||||
"configure_import": "Konfigurera import",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Kopplingen skapades",
|
||||
"connector_deleted_successfully": "Kopplingen togs bort",
|
||||
"connector_duplicated_successfully": "Kopplingen har duplicerats",
|
||||
"connector_name": "Namn på koppling",
|
||||
"connector_name_hint": "Hur denna koppling visas i din instrumentpanel. Förifylls från det uppladdade filnamnet — redigera när som helst.",
|
||||
"connector_status_updated_successfully": "Kopplingens status har uppdaterats",
|
||||
"connector_updated_successfully": "Kopplingen uppdaterades",
|
||||
"connectors": "Kopplingar",
|
||||
"create_mapping": "Skapa mappning",
|
||||
"created_by": "Skapad av",
|
||||
"csv_advanced": "Avancerat",
|
||||
"csv_advanced_hint": "Mindre vanliga fält. Ställ in dem när det är relevant.",
|
||||
"csv_at_least_one_row": "CSV-filen måste innehålla minst en datarad.",
|
||||
"csv_auto_mapped": "Automatiskt mappat",
|
||||
"csv_auto_mapped_tooltip": "Formbricks mappade automatiskt detta från \"{column}\". Du kan ändra det när som helst.",
|
||||
"csv_basic_required": "Grundläggande",
|
||||
"csv_basic_required_hint": "Välj en CSV-kolumn eller ange ett fast värde som tillämpas på varje rad. Obligatoriska fält är markerade med en asterisk.",
|
||||
"csv_column_used_by": "Mappat till: {target}",
|
||||
"csv_columns": "CSV-kolumner",
|
||||
"csv_data_preview": "Förhandsgranskning av data",
|
||||
"csv_empty_column_headers": "CSV-filen innehåller tomma kolumnrubriker. Alla kolumner måste ha ett namn.",
|
||||
"csv_file_too_large": "CSV-filen är för stor. Maxstorlek är 2 MB.",
|
||||
"csv_files_only": "Endast CSV-filer",
|
||||
"csv_first_value": "Exempel: {value}",
|
||||
"csv_fixed_value_action": "Ange ett fast värde…",
|
||||
"csv_fixed_value_label": "Fast värde: {value}",
|
||||
"csv_import": "CSV-import",
|
||||
"csv_import_complete": "CSV-import klar: {successes} lyckades, {failures} misslyckades, {skipped} hoppades över",
|
||||
"csv_import_duplicate_warning": "Om du importerar data två gånger kommer det att skapa dubbletter.",
|
||||
"csv_import_duplicate_warning": "Denna import använder din sparade CSV-mappning. Se till att filen innehåller samma kolumn för inlämnings-ID så att upprepade importer kan matchas till samma källinlämning.",
|
||||
"csv_inconsistent_columns": "Rad {row} har inkonsekventa kolumner. Alla rader måste ha samma rubriker.",
|
||||
"csv_max_records": "Maximalt {max} poster tillåtna.",
|
||||
"csv_now_label": "Nu (använd importtidpunkten)",
|
||||
"csv_pick_column_placeholder": "Välj en kolumn eller ange ett värde…",
|
||||
"csv_required_fields_missing": "Vänligen mappa obligatoriska fält innan du sparar: {fields}",
|
||||
"csv_response_preview": "Exempel: \"{sample}\" → lagras som {target}.",
|
||||
"csv_rows_count": "{count, plural, one {# rad} other {# rader}}",
|
||||
"csv_sample_label": "Exempel-CSV",
|
||||
"csv_saved_mapping_missing_columns": "Den här filen saknar kolumner som krävs av feedbackkällan. Ladda upp en CSV som är kompatibel med det konfigurerade formatet.",
|
||||
"csv_source_context": "Källkontext",
|
||||
"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",
|
||||
"default_connector_name_csv": "CSV-import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey-anslutning",
|
||||
"delete_feedback_record": "Ta bort feedbackpost",
|
||||
"delete_feedback_record_confirmation": "Detta kommer permanent ta bort feedbackposten och radera den från den anslutna katalogen.",
|
||||
"delete_feedback_records_confirmation": "Detta kommer permanent ta bort {count} feedbackposter och radera dem från den anslutna katalogen.",
|
||||
"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?",
|
||||
"drop_a_field_here": "Släpp ett fält här",
|
||||
"drop_field_or": "Släpp fält eller",
|
||||
"edit_csv_mapping": "Redigera CSV-mappning",
|
||||
"edit_source_connection": "Redigera källans anslutning",
|
||||
"enter_name_for_source": "Ange ett namn för denna källa",
|
||||
"enter_value": "Ange värde...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Duplicerad fältmappning för denna källa",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplicerad frågemappning för denna källa",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "Källnamn krävs",
|
||||
"error_connector_questions_required": "Välj minst en fråga",
|
||||
"error_connector_survey_required": "Välj en undersökning",
|
||||
"failed_to_delete_feedback_records": "Misslyckades att ta bort feedbackposter",
|
||||
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
|
||||
"feedback_date": "Aktuellt datum",
|
||||
"feedback_directory": "Feedback-katalog",
|
||||
"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_fields": "Fält för feedbackpost",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"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",
|
||||
"feedback_records_refreshed": "Feedbackposter har uppdaterats",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "Importera {count} rader",
|
||||
"import_via_source_name": "Importera via \"{sourceName}\"",
|
||||
"import_via_source_name": "Importera som \"{sourceName}\"",
|
||||
"importing_data": "Importerar data...",
|
||||
"importing_historical_data": "Importerar historisk data...",
|
||||
"invalid_enum_values": "Ogiltiga värden i kolumnen som är kopplad till {field}",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "Ingen feedbackkatalog länkad",
|
||||
"no_feedback_records": "Inga feedbackposter ännu. Poster visas här när dina connectors börjar skicka data.",
|
||||
"no_formbricks_surveys_available_description": "Det finns inga enkäter i denna arbetsyta ännu. <surveyLink>Skapa en ny enkät</surveyLink> för att använda en som feedbackkälla.",
|
||||
"no_source_fields_loaded": "Inga källfält har laddats än",
|
||||
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
|
||||
"optional": "Valfritt",
|
||||
"or_drag_and_drop": "eller dra och släpp",
|
||||
"question_type_not_supported": "Den här frågetypen stöds inte",
|
||||
"refresh_feedback_records": "Uppdatera feedbackposter",
|
||||
"refreshing_feedback_records": "Uppdaterar feedbackposter...",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "Obligatoriskt",
|
||||
"save_changes": "Spara ändringar",
|
||||
"search_feedback": "Sök feedback",
|
||||
"select_a_survey_to_see_questions": "Välj en enkät för att se dess frågor",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "ange värde",
|
||||
"setup_connection": "Ställ in anslutning",
|
||||
"showing_count_loaded": "Visar {count} poster",
|
||||
"showing_rows": "Visar 3 av {count} rader",
|
||||
"showing_rows": "Visar {visible} av {total} rader",
|
||||
"source": "källa",
|
||||
"source_connect_csv_description": "Importera feedback från CSV-filer",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "Anslut feedback från dina Formbricks-enkäter",
|
||||
"source_fields": "Källfält",
|
||||
"source_id": "Käll-ID",
|
||||
"source_name": "Källnamn",
|
||||
"source_type": "Källtyp",
|
||||
|
||||
+35
-13
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "Otomatik olarak oluşturuldu",
|
||||
"change_file": "Dosyayı değiştir",
|
||||
"click_load_sample_csv": "Sütunları görmek için 'Örnek CSV yükle'ye tıkla",
|
||||
"clear_mapping": "Eşleştirmeyi temizle",
|
||||
"click_to_upload": "Yüklemek için tıkla",
|
||||
"collected_at": "Toplandığı Tarih",
|
||||
"configure_import": "İçe aktarmayı yapılandır",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "Bağlayıcı başarıyla oluşturuldu",
|
||||
"connector_deleted_successfully": "Bağlayıcı başarıyla silindi",
|
||||
"connector_duplicated_successfully": "Bağlayıcı başarıyla kopyalandı",
|
||||
"connector_name": "Bağlayıcı Adı",
|
||||
"connector_name_hint": "Bu bağlayıcının kontrol panelinizde nasıl görüneceği. Yüklenen dosya adından otomatik doldurulur — istediğin zaman düzenleyebilirsin.",
|
||||
"connector_status_updated_successfully": "Bağlayıcı durumu başarıyla güncellendi",
|
||||
"connector_updated_successfully": "Bağlayıcı başarıyla güncellendi",
|
||||
"connectors": "Bağlayıcılar",
|
||||
"create_mapping": "Eşleştirme oluştur",
|
||||
"created_by": "Oluşturan",
|
||||
"csv_advanced": "Gelişmiş",
|
||||
"csv_advanced_hint": "Daha az kullanılan alanlar. Gerektiğinde bunları ayarla.",
|
||||
"csv_at_least_one_row": "CSV en az bir veri satırı içermelidir.",
|
||||
"csv_auto_mapped": "Otomatik eşlendi",
|
||||
"csv_auto_mapped_tooltip": "Formbricks bunu \"{column}\" sütunundan otomatik olarak eşleştirdi. İstediğin zaman değiştirebilirsin.",
|
||||
"csv_basic_required": "Temel",
|
||||
"csv_basic_required_hint": "Bir CSV sütunu seç veya her satıra uygulanacak sabit bir değer belirle. Zorunlu alanlar yıldız işaretiyle gösterilir.",
|
||||
"csv_column_used_by": "Şuraya eşlendi: {target}",
|
||||
"csv_columns": "CSV Sütunları",
|
||||
"csv_data_preview": "Veri önizlemesi",
|
||||
"csv_empty_column_headers": "CSV boş sütun başlıkları içeriyor. Tüm sütunların bir adı olmalıdır.",
|
||||
"csv_file_too_large": "CSV dosyası çok büyük. Maksimum boyut 2MB'dir.",
|
||||
"csv_files_only": "Yalnızca CSV dosyaları",
|
||||
"csv_first_value": "Örnek: {value}",
|
||||
"csv_fixed_value_action": "Sabit bir değer belirle…",
|
||||
"csv_fixed_value_label": "Sabit değer: {value}",
|
||||
"csv_import": "CSV İçe Aktarma",
|
||||
"csv_import_complete": "CSV içe aktarma tamamlandı: {successes} başarılı, {failures} başarısız, {skipped} atlandı",
|
||||
"csv_import_duplicate_warning": "Verileri iki kez içe aktarmak yinelenen kayıtlar oluşturacaktır.",
|
||||
"csv_import_duplicate_warning": "Bu içe aktarma işlemi kayıtlı CSV eşleştirmeni kullanıyor. Tekrarlanan içe aktarmaların aynı kaynak gönderimle eşleşebilmesi için bu dosyanın aynı Gönderim Kimliği sütununu içerdiğinden emin ol.",
|
||||
"csv_inconsistent_columns": "Satır {row} tutarsız sütunlara sahip. Tüm satırlar aynı başlıklara sahip olmalıdır.",
|
||||
"csv_max_records": "Maksimum {max} kayda izin verilir.",
|
||||
"csv_now_label": "Şimdi (içe aktarma zamanını kullan)",
|
||||
"csv_pick_column_placeholder": "Bir sütun seç veya değer belirle…",
|
||||
"csv_required_fields_missing": "Kaydetmeden önce lütfen gerekli alanları eşle: {fields}",
|
||||
"csv_response_preview": "Örnek: \"{sample}\" → {target} olarak saklandı.",
|
||||
"csv_rows_count": "{count, plural, one {# satır} other {# satır}}",
|
||||
"csv_sample_label": "Örnek CSV",
|
||||
"csv_saved_mapping_missing_columns": "Bu dosyada Geri Bildirim Kaynağı için gereken sütunlar eksik, lütfen yapılandırılmış formatla uyumlu bir CSV yükleyin.",
|
||||
"csv_source_context": "Kaynak Bağlamı",
|
||||
"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",
|
||||
"default_connector_name_csv": "CSV İçe Aktarma",
|
||||
"default_connector_name_formbricks": "Formbricks Anket Bağlantısı",
|
||||
"delete_feedback_record": "Geri bildirim kaydını sil",
|
||||
"delete_feedback_record_confirmation": "Bu işlem geri bildirim kaydını kalıcı olarak silecek ve bağlı dizinden kaldıracaktır.",
|
||||
"delete_feedback_records_confirmation": "Bu işlem {count} geri bildirim kaydını kalıcı olarak silecek ve bağlı dizinden kaldıracaktır.",
|
||||
"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?",
|
||||
"drop_a_field_here": "Buraya bir alan bırakın",
|
||||
"drop_field_or": "Alan bırakın veya",
|
||||
"edit_csv_mapping": "CSV eşlemesini düzenle",
|
||||
"edit_source_connection": "Kaynak Bağlantısını Düzenle",
|
||||
"enter_name_for_source": "Bu kaynak için bir ad girin",
|
||||
"enter_value": "Değer girin...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Bu kaynak için yinelenen alan eşlemesi",
|
||||
"error_connector_formbricks_mapping_duplicate": "Bu kaynak için yinelenen soru eşlemesi",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "Kaynak adı gereklidir",
|
||||
"error_connector_questions_required": "En az bir soru seçin",
|
||||
"error_connector_survey_required": "Bir anket seçin",
|
||||
"failed_to_delete_feedback_records": "Geri bildirim kayıtları silinemedi",
|
||||
"failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi",
|
||||
"feedback_date": "Geçerli tarih",
|
||||
"feedback_directory": "Geri Bildirim Dizini",
|
||||
"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_fields": "Geri Bildirim Kayıt Alanları",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"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",
|
||||
"feedback_records_refreshed": "Geri bildirim kayıtları yenilendi",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "{count} satır içe aktar",
|
||||
"import_via_source_name": "\"{sourceName}\" yoluyla içe aktar",
|
||||
"import_via_source_name": "\"{sourceName}\" olarak içe aktar",
|
||||
"importing_data": "Veri içe aktarılıyor...",
|
||||
"importing_historical_data": "Geçmiş veriler içe aktarılıyor...",
|
||||
"invalid_enum_values": "{field} alanına eşlenen sütunda geçersiz değerler",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "Bağlı geri bildirim dizini yok",
|
||||
"no_feedback_records": "Henüz geri bildirim kaydı yok. Bağlayıcıların veri göndermeye başlamasıyla kayıtlar burada görünecek.",
|
||||
"no_formbricks_surveys_available_description": "Bu çalışma alanında henüz anket yok. Geri bildirim kaynağı olarak kullanmak için <surveyLink>Yeni bir anket oluştur</surveyLink>.",
|
||||
"no_source_fields_loaded": "Henüz kaynak alan yüklenmedi",
|
||||
"no_sources_connected": "Henüz bağlı kaynak yok. Başlamak için bir kaynak ekle.",
|
||||
"optional": "İsteğe bağlı",
|
||||
"or_drag_and_drop": "veya sürükle bırak",
|
||||
"question_type_not_supported": "Bu soru türü desteklenmiyor",
|
||||
"refresh_feedback_records": "Geri bildirim kayıtlarını yenile",
|
||||
"refreshing_feedback_records": "Geri bildirim kayıtları yenileniyor...",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "Gerekli",
|
||||
"save_changes": "Değişiklikleri kaydet",
|
||||
"search_feedback": "Geri bildirim ara",
|
||||
"select_a_survey_to_see_questions": "Sorularını görmek için bir anket seç",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "değer belirle",
|
||||
"setup_connection": "Bağlantıyı kur",
|
||||
"showing_count_loaded": "{count} kayıt gösteriliyor",
|
||||
"showing_rows": "{count} satırdan 3'ü gösteriliyor",
|
||||
"showing_rows": "{total} satırdan {visible} tanesi gösteriliyor",
|
||||
"source": "kaynak",
|
||||
"source_connect_csv_description": "CSV dosyalarından geri bildirim içe aktar",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "Formbricks anketlerinizdeki geri bildirimleri bağlayın",
|
||||
"source_fields": "Kaynak Alanları",
|
||||
"source_id": "Kaynak kimliği",
|
||||
"source_name": "Kaynak Adı",
|
||||
"source_type": "Kaynak Türü",
|
||||
|
||||
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "自动生成",
|
||||
"change_file": "更换文件",
|
||||
"click_load_sample_csv": "点击“加载示例 CSV”查看列",
|
||||
"clear_mapping": "清除映射",
|
||||
"click_to_upload": "点击上传",
|
||||
"collected_at": "收集时间",
|
||||
"configure_import": "配置导入",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "连接器创建成功",
|
||||
"connector_deleted_successfully": "连接器删除成功",
|
||||
"connector_duplicated_successfully": "连接器复制成功",
|
||||
"connector_name": "连接器名称",
|
||||
"connector_name_hint": "该连接器在你的仪表盘中的显示名称。会自动根据上传的文件名填写,你可以随时编辑。",
|
||||
"connector_status_updated_successfully": "连接器状态更新成功",
|
||||
"connector_updated_successfully": "连接器更新成功",
|
||||
"connectors": "连接器",
|
||||
"create_mapping": "创建映射",
|
||||
"created_by": "由 创建",
|
||||
"csv_advanced": "高级设置",
|
||||
"csv_advanced_hint": "不常用的字段,按需设置。",
|
||||
"csv_at_least_one_row": "CSV 文件中至少要有一行数据。",
|
||||
"csv_auto_mapped": "自动映射",
|
||||
"csv_auto_mapped_tooltip": "Formbricks 已自动将此项与 “{column}” 匹配。你随时可以更改。",
|
||||
"csv_basic_required": "基础",
|
||||
"csv_basic_required_hint": "选择一个 CSV 列,或设置一个应用于每一行的固定值。必填字段标有星号。",
|
||||
"csv_column_used_by": "映射到:{target}",
|
||||
"csv_columns": "CSV 列",
|
||||
"csv_data_preview": "数据预览",
|
||||
"csv_empty_column_headers": "CSV 文件包含空的列标题。所有列都必须有名称。",
|
||||
"csv_file_too_large": "CSV 文件过大,最大支持 2MB。",
|
||||
"csv_files_only": "仅限 CSV 文件",
|
||||
"csv_first_value": "示例:{value}",
|
||||
"csv_fixed_value_action": "设置固定值…",
|
||||
"csv_fixed_value_label": "固定值:{value}",
|
||||
"csv_import": "CSV 导入",
|
||||
"csv_import_complete": "CSV 导入完成:{successes} 个成功,{failures} 个失败,{skipped} 个跳过",
|
||||
"csv_import_duplicate_warning": "重复导入数据会产生重复记录。",
|
||||
"csv_import_duplicate_warning": "此导入使用您保存的 CSV 映射。请确保此文件包含相同的提交 ID 列,以便重复导入可以匹配到相同的源提交。",
|
||||
"csv_inconsistent_columns": "第 {row} 行的列数不一致。所有行必须有相同的表头。",
|
||||
"csv_max_records": "最多允许 {max} 条记录。",
|
||||
"csv_now_label": "当前(使用导入时间)",
|
||||
"csv_pick_column_placeholder": "选择列或设置值…",
|
||||
"csv_required_fields_missing": "请在保存前映射必填字段:{fields}",
|
||||
"csv_response_preview": "示例:“{sample}” → 存储为 {target}。",
|
||||
"csv_rows_count": "{count, plural, other {# 行}}",
|
||||
"csv_sample_label": "CSV 示例",
|
||||
"csv_saved_mapping_missing_columns": "此文件缺少反馈来源所需的列,请上传与配置格式兼容的 CSV 文件。",
|
||||
"csv_source_context": "来源上下文",
|
||||
"csv_source_context_hint": "标识这批反馈的来源。",
|
||||
"csv_unmapped_columns": "未映射列({count}):{columns}",
|
||||
"csv_unmapped_columns_explainer": "这些列未被任何反馈记录字段使用,导入时将被忽略。",
|
||||
"custom_source_type": "自定义源类型",
|
||||
"custom_source_type_placeholder": "输入自定义来源类型",
|
||||
"default_connector_name_csv": "CSV 导入",
|
||||
"default_connector_name_formbricks": "Formbricks 调查连接",
|
||||
"delete_feedback_record": "删除反馈记录",
|
||||
"delete_feedback_record_confirmation": "这将永久删除该反馈记录并从关联目录中移除。",
|
||||
"delete_feedback_records_confirmation": "这将永久删除 {count} 条反馈记录并从关联目录中移除。",
|
||||
"discard_feedback_record_changes_description": "如果关闭此抽屉,您的更改将会丢失。",
|
||||
"discard_feedback_record_changes_title": "放弃未保存的更改?",
|
||||
"drop_a_field_here": "将字段拖到这里",
|
||||
"drop_field_or": "拖放字段或",
|
||||
"edit_csv_mapping": "编辑 CSV 映射",
|
||||
"edit_source_connection": "编辑源连接",
|
||||
"enter_name_for_source": "为此来源输入名称",
|
||||
"enter_value": "请输入值...",
|
||||
"enum": "枚举",
|
||||
"error_connector_field_mapping_duplicate": "此来源存在重复的字段映射",
|
||||
"error_connector_formbricks_mapping_duplicate": "此来源存在重复的问题映射",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "数据源名称为必填项",
|
||||
"error_connector_questions_required": "请至少选择一个问题",
|
||||
"error_connector_survey_required": "请选择一个调查问卷",
|
||||
"failed_to_delete_feedback_records": "删除反馈记录失败",
|
||||
"failed_to_load_feedback_records": "加载反馈记录失败",
|
||||
"feedback_date": "当前日期",
|
||||
"feedback_directory": "反馈目录",
|
||||
"feedback_record_created_successfully": "反馈记录创建成功",
|
||||
"feedback_record_deleted_successfully": "反馈记录已成功删除",
|
||||
"feedback_record_details": "反馈记录详情",
|
||||
"feedback_record_details_description": "查看并更新反馈记录字段。",
|
||||
"feedback_record_fields": "反馈记录字段",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "反馈记录更新成功",
|
||||
"feedback_record_value_required": "所选字段类型需要一个值",
|
||||
"feedback_records": "反馈记录",
|
||||
"feedback_records_deleted_successfully": "已删除 {count} 条反馈记录",
|
||||
"feedback_records_partially_deleted": "已删除 {succeeded} 条(共 {total} 条)反馈记录",
|
||||
"feedback_records_refreshed": "反馈记录已刷新",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "导入{count}行数据",
|
||||
"import_via_source_name": "通过“{sourceName}”导入",
|
||||
"import_via_source_name": "以“{sourceName}”导入",
|
||||
"importing_data": "正在导入数据…",
|
||||
"importing_historical_data": "正在导入历史数据…",
|
||||
"invalid_enum_values": "映射到 {field} 的列中存在无效值",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "未关联反馈目录",
|
||||
"no_feedback_records": "暂无反馈记录。当你的连接器开始发送数据后,记录会显示在这里。",
|
||||
"no_formbricks_surveys_available_description": "此工作区还没有调查。<surveyLink>创建新调查</surveyLink>,以将其用作反馈来源。",
|
||||
"no_source_fields_loaded": "尚未加载源字段",
|
||||
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
|
||||
"optional": "可选",
|
||||
"or_drag_and_drop": "或拖放",
|
||||
"question_type_not_supported": "不支持此问题类型",
|
||||
"refresh_feedback_records": "刷新反馈记录",
|
||||
"refreshing_feedback_records": "正在刷新反馈记录…",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "必填",
|
||||
"save_changes": "保存更改",
|
||||
"search_feedback": "搜索反馈",
|
||||
"select_a_survey_to_see_questions": "请选择一个调查以查看其问题",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "设置值",
|
||||
"setup_connection": "设置连接",
|
||||
"showing_count_loaded": "显示 {count} 条记录",
|
||||
"showing_rows": "显示 {count} 行中的 3 行",
|
||||
"showing_rows": "显示第 {visible} 行,共 {total} 行",
|
||||
"source": "source",
|
||||
"source_connect_csv_description": "从 CSV 文件导入反馈",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "连接来自你 Formbricks 调查的反馈",
|
||||
"source_fields": "来源字段",
|
||||
"source_id": "源ID",
|
||||
"source_name": "来源名称",
|
||||
"source_type": "来源类型",
|
||||
|
||||
@@ -3654,7 +3654,7 @@
|
||||
"api_ingestion_settings_description": "Send feedback records using the Management API.",
|
||||
"auto_generated": "自動生成",
|
||||
"change_file": "更換檔案",
|
||||
"click_load_sample_csv": "點擊「載入範例 CSV」以查看欄位",
|
||||
"clear_mapping": "清除對應",
|
||||
"click_to_upload": "點擊以上傳",
|
||||
"collected_at": "收集時間",
|
||||
"configure_import": "設定匯入",
|
||||
@@ -3662,33 +3662,57 @@
|
||||
"connector_created_successfully": "連接器建立成功",
|
||||
"connector_deleted_successfully": "連接器刪除成功",
|
||||
"connector_duplicated_successfully": "連接器複製成功",
|
||||
"connector_name": "連接器名稱",
|
||||
"connector_name_hint": "此連接器在你的儀表板中顯示的名稱。會自動從上傳的檔案名稱填入,隨時可以編輯。",
|
||||
"connector_status_updated_successfully": "連接器狀態更新成功",
|
||||
"connector_updated_successfully": "連接器更新成功",
|
||||
"connectors": "連接器",
|
||||
"create_mapping": "建立對應關係",
|
||||
"created_by": "建立者",
|
||||
"csv_advanced": "進階",
|
||||
"csv_advanced_hint": "較少使用的欄位。需要時再設定。",
|
||||
"csv_at_least_one_row": "CSV 必須至少包含一筆資料列。",
|
||||
"csv_auto_mapped": "自動對應",
|
||||
"csv_auto_mapped_tooltip": "Formbricks 已自動從「{column}」對應此欄位。你可以隨時變更。",
|
||||
"csv_basic_required": "基本",
|
||||
"csv_basic_required_hint": "選擇一個 CSV 欄位,或設定套用到每一列的固定值。必填欄位會標示星號。",
|
||||
"csv_column_used_by": "已對應至:{target}",
|
||||
"csv_columns": "CSV 欄位",
|
||||
"csv_data_preview": "資料預覽",
|
||||
"csv_empty_column_headers": "CSV 包含空白的欄位標題。所有欄位都必須有名稱。",
|
||||
"csv_file_too_large": "CSV 檔案過大,最大限制為 2MB。",
|
||||
"csv_files_only": "僅限 CSV 檔案",
|
||||
"csv_first_value": "範例:{value}",
|
||||
"csv_fixed_value_action": "設定固定值…",
|
||||
"csv_fixed_value_label": "固定值:{value}",
|
||||
"csv_import": "CSV 匯入",
|
||||
"csv_import_complete": "CSV 匯入完成:{successes} 筆成功,{failures} 筆失敗,{skipped} 筆略過",
|
||||
"csv_import_duplicate_warning": "匯入已經匯入過的資料,可能會產生重複紀錄。",
|
||||
"csv_import_duplicate_warning": "此匯入使用您儲存的 CSV 對應設定。請確保此檔案包含相同的提交 ID 欄位,以便重複匯入時能對應到相同的來源提交。",
|
||||
"csv_inconsistent_columns": "第 {row} 列的欄位數不一致。所有列必須有相同的標題。",
|
||||
"csv_max_records": "最多允許 {max} 筆紀錄。",
|
||||
"csv_now_label": "現在(使用匯入時間)",
|
||||
"csv_pick_column_placeholder": "選擇欄位或設定值…",
|
||||
"csv_required_fields_missing": "請在儲存前對應必填欄位:{fields}",
|
||||
"csv_response_preview": "範例:「{sample}」→ 儲存為 {target}。",
|
||||
"csv_rows_count": "{count, plural, other {# 列}}",
|
||||
"csv_sample_label": "CSV 範例",
|
||||
"csv_saved_mapping_missing_columns": "此檔案缺少回饋來源所需的欄位,請上傳與已設定格式相容的 CSV 檔案。",
|
||||
"csv_source_context": "來源情境",
|
||||
"csv_source_context_hint": "識別這批意見回饋的來源。",
|
||||
"csv_unmapped_columns": "未對應的欄位({count} 個):{columns}",
|
||||
"csv_unmapped_columns_explainer": "這些欄位沒有被任何意見回饋記錄欄位使用。匯入時會被忽略。",
|
||||
"custom_source_type": "自訂來源類型",
|
||||
"custom_source_type_placeholder": "輸入自訂來源類型",
|
||||
"default_connector_name_csv": "CSV 匯入",
|
||||
"default_connector_name_formbricks": "Formbricks 問卷連線",
|
||||
"delete_feedback_record": "刪除意見回饋記錄",
|
||||
"delete_feedback_record_confirmation": "這將永久刪除此意見回饋記錄,並從已連結的目錄中移除。",
|
||||
"delete_feedback_records_confirmation": "這將永久刪除 {count} 筆意見回饋記錄,並從已連結的目錄中移除。",
|
||||
"discard_feedback_record_changes_description": "如果關閉此抽屜,您的變更將會遺失。",
|
||||
"discard_feedback_record_changes_title": "放棄未儲存的變更?",
|
||||
"drop_a_field_here": "請將欄位拖曳到這裡",
|
||||
"drop_field_or": "拖曳欄位或",
|
||||
"edit_csv_mapping": "編輯 CSV 對應",
|
||||
"edit_source_connection": "編輯來源連線",
|
||||
"enter_name_for_source": "請輸入此來源的名稱",
|
||||
"enter_value": "請輸入值……",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "此來源的欄位對應重複",
|
||||
"error_connector_formbricks_mapping_duplicate": "此來源的問題對應重複",
|
||||
@@ -3696,17 +3720,19 @@
|
||||
"error_connector_name_required": "來源名稱為必填項目",
|
||||
"error_connector_questions_required": "請至少選擇一個問題",
|
||||
"error_connector_survey_required": "請選擇一個調查問卷",
|
||||
"failed_to_delete_feedback_records": "刪除意見回饋記錄失敗",
|
||||
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
|
||||
"feedback_date": "目前日期",
|
||||
"feedback_directory": "意見回饋目錄",
|
||||
"feedback_record_created_successfully": "回饋記錄創建成功",
|
||||
"feedback_record_deleted_successfully": "意見回饋記錄已成功刪除",
|
||||
"feedback_record_details": "反饋記錄詳情",
|
||||
"feedback_record_details_description": "查看並更新回饋記錄欄位。",
|
||||
"feedback_record_fields": "回饋紀錄欄位",
|
||||
"feedback_record_mcp": "Feedback Record MCP",
|
||||
"feedback_record_updated_successfully": "回饋記錄更新成功",
|
||||
"feedback_record_value_required": "所選欄位類型需要一個值",
|
||||
"feedback_records": "回饋紀錄",
|
||||
"feedback_records_deleted_successfully": "已刪除 {count} 筆意見回饋記錄",
|
||||
"feedback_records_partially_deleted": "已刪除 {succeeded} 筆意見回饋記錄,共 {total} 筆",
|
||||
"feedback_records_refreshed": "回饋紀錄已更新",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
@@ -3725,7 +3751,7 @@
|
||||
"import_historical_responses": "Import historical responses",
|
||||
"import_historical_responses_description": "Import existing responses from this survey now.",
|
||||
"import_rows": "匯入 {count} 筆資料",
|
||||
"import_via_source_name": "透過“{sourceName}”導入",
|
||||
"import_via_source_name": "匯入為「{sourceName}」",
|
||||
"importing_data": "正在匯入資料…",
|
||||
"importing_historical_data": "正在匯入歷史資料…",
|
||||
"invalid_enum_values": "對應到 {field} 欄位的值無效",
|
||||
@@ -3744,15 +3770,12 @@
|
||||
"no_feedback_directory_linked_title": "未連結意見回饋目錄",
|
||||
"no_feedback_records": "目前尚無回饋紀錄。當你的連接器開始傳送資料時,紀錄會顯示在這裡。",
|
||||
"no_formbricks_surveys_available_description": "此工作區尚無問卷。<surveyLink>建立新問卷</surveyLink>,以將其用作回饋來源。",
|
||||
"no_source_fields_loaded": "尚未載入來源欄位",
|
||||
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
|
||||
"optional": "選填",
|
||||
"or_drag_and_drop": "或拖曳檔案",
|
||||
"question_type_not_supported": "不支援此題型",
|
||||
"refresh_feedback_records": "重新整理回饋紀錄",
|
||||
"refreshing_feedback_records": "正在更新回饋紀錄…",
|
||||
"request_feedback_source": "Request source integration",
|
||||
"required": "必填",
|
||||
"save_changes": "儲存變更",
|
||||
"search_feedback": "搜尋意見回饋",
|
||||
"select_a_survey_to_see_questions": "請選擇問卷以查看其問題",
|
||||
@@ -3781,12 +3804,11 @@
|
||||
"set_value": "設定值",
|
||||
"setup_connection": "設定連線",
|
||||
"showing_count_loaded": "顯示 {count} 筆記錄",
|
||||
"showing_rows": "顯示 {count} 筆資料中的 3 筆",
|
||||
"showing_rows": "顯示 {total} 列中的 {visible} 列",
|
||||
"source": "來源",
|
||||
"source_connect_csv_description": "從 CSV 檔案匯入回饋",
|
||||
"source_connect_feedback_record_mcp_description": "Send feedback records through the MCP integration.",
|
||||
"source_connect_formbricks_description": "連接來自你 Formbricks 問卷的回饋",
|
||||
"source_fields": "來源欄位",
|
||||
"source_id": "來源ID",
|
||||
"source_name": "來源名稱",
|
||||
"source_type": "來源類型",
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
"use server";
|
||||
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory";
|
||||
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service";
|
||||
import {
|
||||
createFeedbackRecord,
|
||||
deleteFeedbackRecord,
|
||||
retrieveFeedbackRecord,
|
||||
updateFeedbackRecord,
|
||||
} from "@/modules/hub/service";
|
||||
import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types";
|
||||
import {
|
||||
TCreateFeedbackRecordAction,
|
||||
TRetrieveFeedbackRecordAction,
|
||||
TUpdateFeedbackRecordAction,
|
||||
ZCreateFeedbackRecordAction,
|
||||
ZDeleteFeedbackRecordAction,
|
||||
ZRetrieveFeedbackRecordAction,
|
||||
ZUpdateFeedbackRecordAction,
|
||||
} from "./types";
|
||||
@@ -50,10 +56,14 @@ const getWorkspaceDirectoryIds = async (workspaceId: string): Promise<Set<string
|
||||
return new Set(directories.map((directory) => directory.id));
|
||||
};
|
||||
|
||||
const assertRecordBelongsToWorkspace = (directoryIds: Set<string>, tenantId: string): void => {
|
||||
const assertRecordBelongsToWorkspace = (
|
||||
directoryIds: Set<string>,
|
||||
tenantId: string,
|
||||
recordId: string | null
|
||||
): void => {
|
||||
if (!directoryIds.has(tenantId)) {
|
||||
// Throw a generic error indistinguishable from "not found" to prevent IDOR
|
||||
throw new Error("Feedback record not found");
|
||||
// Same error shape as a genuine "not found" to prevent IDOR via response differences
|
||||
throw new ResourceNotFoundError("Feedback record", recordId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,10 +84,14 @@ export const retrieveFeedbackRecordAction = authenticatedActionClient
|
||||
|
||||
const recordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!recordResult.data || recordResult.error) {
|
||||
throw new Error("Feedback record not found");
|
||||
throw new ResourceNotFoundError("Feedback record", parsedInput.recordId);
|
||||
}
|
||||
|
||||
assertRecordBelongsToWorkspace(workspaceDirectoryIds, recordResult.data.tenant_id);
|
||||
assertRecordBelongsToWorkspace(
|
||||
workspaceDirectoryIds,
|
||||
recordResult.data.tenant_id,
|
||||
parsedInput.recordId
|
||||
);
|
||||
|
||||
return recordResult.data;
|
||||
}
|
||||
@@ -96,7 +110,7 @@ export const createFeedbackRecordAction = authenticatedActionClient
|
||||
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
|
||||
|
||||
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
|
||||
assertRecordBelongsToWorkspace(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
|
||||
assertRecordBelongsToWorkspace(workspaceDirectoryIds, parsedInput.recordInput.tenant_id, null);
|
||||
|
||||
const { recordInput } = parsedInput;
|
||||
const createParams: FeedbackRecordCreateParams = {
|
||||
@@ -146,10 +160,14 @@ export const updateFeedbackRecordAction = authenticatedActionClient
|
||||
|
||||
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!currentRecordResult.data || currentRecordResult.error) {
|
||||
throw new Error("Feedback record not found");
|
||||
throw new ResourceNotFoundError("Feedback record", parsedInput.recordId);
|
||||
}
|
||||
|
||||
assertRecordBelongsToWorkspace(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
|
||||
assertRecordBelongsToWorkspace(
|
||||
workspaceDirectoryIds,
|
||||
currentRecordResult.data.tenant_id,
|
||||
parsedInput.recordId
|
||||
);
|
||||
|
||||
const { updateInput } = parsedInput;
|
||||
const updateParams: FeedbackRecordUpdateParams = {
|
||||
@@ -176,3 +194,30 @@ export const updateFeedbackRecordAction = authenticatedActionClient
|
||||
return updateResult.data;
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteFeedbackRecordAction = authenticatedActionClient
|
||||
.inputSchema(ZDeleteFeedbackRecordAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const [, workspaceDirectoryIds] = await Promise.all([
|
||||
ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"),
|
||||
getWorkspaceDirectoryIds(parsedInput.workspaceId),
|
||||
]);
|
||||
|
||||
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!currentRecordResult.data || currentRecordResult.error) {
|
||||
throw new ResourceNotFoundError("Feedback record", parsedInput.recordId);
|
||||
}
|
||||
|
||||
assertRecordBelongsToWorkspace(
|
||||
workspaceDirectoryIds,
|
||||
currentRecordResult.data.tenant_id,
|
||||
parsedInput.recordId
|
||||
);
|
||||
|
||||
const deleteResult = await deleteFeedbackRecord(parsedInput.recordId);
|
||||
if (!deleteResult.data || deleteResult.error) {
|
||||
throw new Error(deleteResult.error?.message || "Failed to delete feedback record");
|
||||
}
|
||||
|
||||
return { recordId: parsedInput.recordId };
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import {
|
||||
createFeedbackRecordAction,
|
||||
deleteFeedbackRecordAction,
|
||||
retrieveFeedbackRecordAction,
|
||||
updateFeedbackRecordAction,
|
||||
} from "../actions";
|
||||
@@ -87,6 +89,8 @@ export const FeedbackRecordFormDrawer = ({
|
||||
const [isLoadingRecord, setIsLoadingRecord] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
|
||||
|
||||
@@ -283,6 +287,24 @@ export const FeedbackRecordFormDrawer = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!recordId) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteFeedbackRecordAction({ workspaceId, recordId });
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("workspace.unify.feedback_record_deleted_successfully"));
|
||||
setIsDeleteDialogOpen(false);
|
||||
await onSuccess();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
form.clearErrors();
|
||||
|
||||
@@ -785,15 +807,30 @@ export const FeedbackRecordFormDrawer = ({
|
||||
</FormProvider>
|
||||
)}
|
||||
|
||||
<SheetFooter className="mt-2">
|
||||
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
|
||||
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
|
||||
<SheetFooter className="mt-2 sm:justify-between">
|
||||
{isEditMode && canWrite && recordId ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
disabled={isSubmitting || isLoadingRecord || isDeleting}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={requestClose} disabled={isSubmitting || isDeleting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
disabled={isLoadingRecord || isDeleting}>
|
||||
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -809,6 +846,15 @@ export const FeedbackRecordFormDrawer = ({
|
||||
onDecline={() => setIsDiscardDialogOpen(false)}
|
||||
onConfirm={handleDiscardChanges}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
deleteWhat={t("workspace.unify.delete_feedback_record")}
|
||||
text={t("workspace.unify.delete_feedback_record_confirmation")}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TConnectorFieldMapping } from "@formbricks/types/connector";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -12,7 +13,7 @@ interface FeedbackRecordsPageClientProps {
|
||||
initialRecords: FeedbackRecordData[];
|
||||
initialCursors: Record<string, string>;
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
csvSources: { id: string; name: string; fieldMappings: TConnectorFieldMapping[] }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface FeedbackRecordsTableToolbarLeftProps {
|
||||
selectedCount: number;
|
||||
recordsCount: number;
|
||||
isEmpty: boolean;
|
||||
onClearSelection: () => void;
|
||||
onBulkDelete: () => void;
|
||||
}
|
||||
|
||||
export const FeedbackRecordsTableToolbarLeft = ({
|
||||
selectedCount,
|
||||
recordsCount,
|
||||
isEmpty,
|
||||
onClearSelection,
|
||||
onBulkDelete,
|
||||
}: Readonly<FeedbackRecordsTableToolbarLeftProps>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (selectedCount > 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-x-2 rounded-md bg-primary p-1 px-2 text-xs text-white">
|
||||
<span className="lowercase">
|
||||
{`${selectedCount} ${t("workspace.unify.feedback_records").toLowerCase()} ${t("common.selected")}`}
|
||||
</span>
|
||||
<span>|</span>
|
||||
<Button variant="outline" size="sm" className="h-6 border-none px-2" onClick={onClearSelection}>
|
||||
{t("common.clear_selection")}
|
||||
</Button>
|
||||
<span>|</span>
|
||||
<Button variant="secondary" size="sm" className="h-6 gap-1 px-2" onClick={onBulkDelete}>
|
||||
{t("common.delete")}
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return <span />;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", { count: recordsCount })}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -15,12 +15,15 @@ import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TConnectorFieldMapping } from "@formbricks/types/connector";
|
||||
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -29,9 +32,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { deleteFeedbackRecordAction } from "../actions";
|
||||
import { formatSourceType } from "../lib/utils";
|
||||
import { CsvImportModal } from "../sources/components/csv-import-modal";
|
||||
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
|
||||
import { FeedbackRecordsTableToolbarLeft } from "./feedback-records-table-toolbar-left";
|
||||
|
||||
const RECORDS_PER_PAGE = 50;
|
||||
|
||||
@@ -65,7 +70,7 @@ interface FeedbackRecordsTableProps {
|
||||
initialRecords: FeedbackRecordData[];
|
||||
initialCursors: Record<string, string>;
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
csvSources: { id: string; name: string; fieldMappings: TConnectorFieldMapping[] }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
@@ -86,9 +91,45 @@ export const FeedbackRecordsTable = ({
|
||||
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit");
|
||||
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
|
||||
const [csvImportSource, setCsvImportSource] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
fieldMappings: TConnectorFieldMapping[];
|
||||
} | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const hasMore = Object.keys(cursors).length > 0;
|
||||
const selectedCount = selectedIds.size;
|
||||
const allOnPageSelected = records.length > 0 && records.every((record) => selectedIds.has(record.id));
|
||||
const someOnPageSelected = records.some((record) => selectedIds.has(record.id));
|
||||
|
||||
const toggleAllOnPage = (checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
records.forEach((record) => next.add(record.id));
|
||||
} else {
|
||||
records.forEach((record) => next.delete(record.id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleOne = (recordId: string, checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
next.add(recordId);
|
||||
} else {
|
||||
next.delete(recordId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelectedIds(new Set());
|
||||
|
||||
const directories = useMemo(
|
||||
() =>
|
||||
@@ -160,6 +201,7 @@ export const FeedbackRecordsTable = ({
|
||||
const mergedRecords = result.records.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1));
|
||||
setRecords(mergedRecords);
|
||||
setCursors(result.newCursors);
|
||||
setSelectedIds(new Set());
|
||||
setIsRefreshing(false);
|
||||
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
|
||||
};
|
||||
@@ -199,6 +241,56 @@ export const FeedbackRecordsTable = ({
|
||||
|
||||
const isEmpty = records.length === 0 && !isRefreshing;
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
setIsDeleting(true);
|
||||
const CHUNK_SIZE = 5;
|
||||
const failedIds: string[] = [];
|
||||
try {
|
||||
for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
|
||||
const chunk = ids.slice(i, i + CHUNK_SIZE);
|
||||
const results = await Promise.all(
|
||||
chunk.map(async (recordId) => ({
|
||||
recordId,
|
||||
result: await deleteFeedbackRecordAction({ workspaceId, recordId }),
|
||||
}))
|
||||
);
|
||||
results.forEach(({ recordId, result }) => {
|
||||
if (!result?.data) failedIds.push(recordId);
|
||||
});
|
||||
}
|
||||
|
||||
const succeeded = ids.filter((id) => !failedIds.includes(id));
|
||||
if (succeeded.length > 0) {
|
||||
setRecords((prev) => prev.filter((record) => !succeeded.includes(record.id)));
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
succeeded.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (failedIds.length === 0) {
|
||||
toast.success(
|
||||
t("workspace.unify.feedback_records_deleted_successfully", { count: succeeded.length })
|
||||
);
|
||||
} else if (succeeded.length === 0) {
|
||||
toast.error(t("workspace.unify.failed_to_delete_feedback_records"));
|
||||
} else {
|
||||
toast.error(
|
||||
t("workspace.unify.feedback_records_partially_deleted", {
|
||||
succeeded: succeeded.length,
|
||||
total: ids.length,
|
||||
})
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDrawer = (recordId: string) => {
|
||||
setDrawerMode("edit");
|
||||
setDrawerRecordId(recordId);
|
||||
@@ -213,19 +305,24 @@ export const FeedbackRecordsTable = ({
|
||||
|
||||
const hasCsvSources = csvSources.length > 0;
|
||||
|
||||
let headerCheckboxChecked: boolean | "indeterminate" = false;
|
||||
if (allOnPageSelected) {
|
||||
headerCheckboxChecked = true;
|
||||
} else if (someOnPageSelected) {
|
||||
headerCheckboxChecked = "indeterminate";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{isEmpty ? (
|
||||
<span />
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", {
|
||||
count: records.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<FeedbackRecordsTableToolbarLeft
|
||||
selectedCount={selectedCount}
|
||||
recordsCount={records.length}
|
||||
isEmpty={isEmpty}
|
||||
onClearSelection={clearSelection}
|
||||
onBulkDelete={() => setIsBulkDeleteDialogOpen(true)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite &&
|
||||
(hasCsvSources ? (
|
||||
@@ -280,6 +377,13 @@ export const FeedbackRecordsTable = ({
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="w-10 px-4 py-3">
|
||||
<Checkbox
|
||||
aria-label={t("common.select_all")}
|
||||
checked={headerCheckboxChecked}
|
||||
onCheckedChange={(checked) => toggleAllOnPage(checked === true)}
|
||||
/>
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
@@ -292,7 +396,7 @@ export const FeedbackRecordsTable = ({
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<td colSpan={8}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
@@ -308,6 +412,8 @@ export const FeedbackRecordsTable = ({
|
||||
workspaceId={workspaceId}
|
||||
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
|
||||
t={t}
|
||||
isSelected={selectedIds.has(record.id)}
|
||||
onSelectChange={(checked) => toggleOne(record.id, checked)}
|
||||
onClick={() => openEditDrawer(record.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -342,6 +448,15 @@ export const FeedbackRecordsTable = ({
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
open={isBulkDeleteDialogOpen}
|
||||
setOpen={setIsBulkDeleteDialogOpen}
|
||||
deleteWhat={t("workspace.unify.feedback_records")}
|
||||
text={t("workspace.unify.delete_feedback_records_confirmation", { count: selectedCount })}
|
||||
onDelete={handleBulkDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
{csvImportSource && (
|
||||
<CsvImportModal
|
||||
open={csvImportSource !== null}
|
||||
@@ -352,6 +467,7 @@ export const FeedbackRecordsTable = ({
|
||||
}}
|
||||
connectorId={csvImportSource.id}
|
||||
workspaceId={workspaceId}
|
||||
fieldMappings={csvImportSource.fieldMappings}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -363,12 +479,16 @@ const FeedbackRecordRow = ({
|
||||
workspaceId,
|
||||
locale,
|
||||
t,
|
||||
isSelected,
|
||||
onSelectChange,
|
||||
onClick,
|
||||
}: {
|
||||
record: FeedbackRecordData;
|
||||
workspaceId: string;
|
||||
locale: string;
|
||||
t: TFunction;
|
||||
isSelected: boolean;
|
||||
onSelectChange: (checked: boolean) => void;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const value = formatValue(record, t, locale);
|
||||
@@ -379,10 +499,10 @@ const FeedbackRecordRow = ({
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="cursor-pointer text-sm text-slate-700 transition-colors focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
||||
className={`cursor-pointer text-sm text-slate-700 transition-colors focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 ${isSelected ? "bg-slate-50" : ""}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={record.field_label ?? record.field_id}
|
||||
aria-selected={isSelected}
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
@@ -390,6 +510,16 @@ const FeedbackRecordRow = ({
|
||||
onClick();
|
||||
}
|
||||
}}>
|
||||
<td
|
||||
className="w-10 px-4 py-3"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}>
|
||||
<Checkbox
|
||||
aria-label={record.field_label ?? record.field_id}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => onSelectChange(checked === true)}
|
||||
/>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
|
||||
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
|
||||
</td>
|
||||
|
||||
@@ -108,7 +108,11 @@ export default async function UnifyFeedbackRecordsPage(
|
||||
const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name]));
|
||||
const csvSources = connectors
|
||||
.filter((connector) => connector.type === "csv")
|
||||
.map((connector) => ({ id: connector.id, name: connector.name }));
|
||||
.map((connector) => ({
|
||||
id: connector.id,
|
||||
name: connector.name,
|
||||
fieldMappings: connector.fieldMappings,
|
||||
}));
|
||||
|
||||
return (
|
||||
<FeedbackRecordsPageClient
|
||||
|
||||
@@ -236,6 +236,7 @@ export function ConnectorsSection({
|
||||
onOpenChange={(open) => !open && setCsvImportConnector(null)}
|
||||
connectorId={csvImportConnector.id}
|
||||
workspaceId={csvImportConnector.workspaceId}
|
||||
fieldMappings={csvImportConnector.fieldMappings}
|
||||
onOpenEditConnector={() => {
|
||||
setEditingConnector(csvImportConnector);
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2Icon, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
} from "@/modules/ui/components/select";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import {
|
||||
CSV_HIDDEN_STATIC_MAPPINGS,
|
||||
CSV_PROTECTED_TARGET_IDS,
|
||||
TCreateConnectorStep,
|
||||
TFieldMapping,
|
||||
TFormbricksConnectorForm,
|
||||
@@ -54,9 +56,8 @@ import {
|
||||
import {
|
||||
TConnectorOptionId,
|
||||
TEnumValidationError,
|
||||
areAllRequiredFieldsMapped,
|
||||
areAllRequiredCsvFieldsMapped,
|
||||
isConnectorNameValid,
|
||||
parseCSVColumnsToFields,
|
||||
toggleQuestionId,
|
||||
validateEnumMappings,
|
||||
} from "../utils";
|
||||
@@ -157,6 +158,7 @@ export const CreateConnectorModal = ({
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories[0]?.id ?? null);
|
||||
const userEditedConnectorNameRef = useRef(false);
|
||||
|
||||
const formbricksValues = formbricksForm.watch();
|
||||
const selectedSurveyId = formbricksValues.surveyId;
|
||||
@@ -225,6 +227,7 @@ export const CreateConnectorModal = ({
|
||||
setEnumValidationErrors([]);
|
||||
setResponseCountBySurvey({});
|
||||
setCsvConnectorName("");
|
||||
userEditedConnectorNameRef.current = false;
|
||||
setIsImporting(false);
|
||||
setIsCreating(false);
|
||||
setSelectedDirectoryId(directories[0]?.id ?? null);
|
||||
@@ -317,7 +320,7 @@ export const CreateConnectorModal = ({
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(importResult), t));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -356,6 +359,15 @@ export const CreateConnectorModal = ({
|
||||
|
||||
const handleCreateCsvConnector = async () => {
|
||||
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
|
||||
|
||||
const requiredCheck = areAllRequiredCsvFieldsMapped(mappings);
|
||||
if (!requiredCheck.valid) {
|
||||
toast.error(
|
||||
t("workspace.unify.csv_required_fields_missing", { fields: requiredCheck.missing.join(", ") })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (csvParsedData.length > 0) {
|
||||
const errors = validateEnumMappings(mappings, csvParsedData);
|
||||
if (errors.length > 0) {
|
||||
@@ -367,11 +379,17 @@ export const CreateConnectorModal = ({
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
// Strip any user-supplied tenant_id and merge hidden static mappings (source_type=csv).
|
||||
const userMappings = mappings.filter((m) =>
|
||||
CSV_PROTECTED_TARGET_IDS.every((id) => m.targetFieldId !== id)
|
||||
);
|
||||
const fieldMappings = [...userMappings, ...CSV_HIDDEN_STATIC_MAPPINGS];
|
||||
|
||||
const connectorId = await onCreateConnector({
|
||||
name: csvConnectorName.trim(),
|
||||
type: "csv",
|
||||
feedbackDirectoryId: selectedDirectoryId,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
fieldMappings,
|
||||
});
|
||||
|
||||
if (!connectorId) {
|
||||
@@ -389,13 +407,16 @@ export const CreateConnectorModal = ({
|
||||
};
|
||||
|
||||
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
|
||||
const areCsvRequiredFieldsMapped = areAllRequiredFieldsMapped(mappings);
|
||||
const areCsvRequiredFieldsMapped = areAllRequiredCsvFieldsMapped(mappings).valid;
|
||||
|
||||
const handleLoadSourceFields = () => {
|
||||
if (selectedType === "csv") {
|
||||
const fields = parseCSVColumnsToFields("timestamp,customer_id,rating,feedback_text,category");
|
||||
setSourceFields(fields);
|
||||
}
|
||||
const handleSuggestConnectorName = (name: string) => {
|
||||
if (userEditedConnectorNameRef.current) return;
|
||||
setCsvConnectorName(name);
|
||||
};
|
||||
|
||||
const handleCsvConnectorNameChange = (value: string) => {
|
||||
userEditedConnectorNameRef.current = true;
|
||||
setCsvConnectorName(value);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -537,13 +558,14 @@ export const CreateConnectorModal = ({
|
||||
{currentStep === "mapping" && selectedType === "csv" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
|
||||
<Label htmlFor="connectorName">{t("workspace.unify.connector_name")}</Label>
|
||||
<Input
|
||||
id="connectorName"
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
onChange={(event) => handleCsvConnectorNameChange(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">{t("workspace.unify.connector_name_hint")}</p>
|
||||
</div>
|
||||
|
||||
{directories.length === 0 && <NoFeedbackDirectoryAlert workspaceId={workspaceId} t={t} />}
|
||||
@@ -557,8 +579,8 @@ export const CreateConnectorModal = ({
|
||||
setEnumValidationErrors([]);
|
||||
}}
|
||||
onSourceFieldsChange={setSourceFields}
|
||||
onLoadSampleCSV={handleLoadSourceFields}
|
||||
onParsedDataChange={setCsvParsedData}
|
||||
onSuggestConnectorName={handleSuggestConnectorName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { ArrowUpFromLineIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ArrowUpFromLineIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types";
|
||||
import { validateCsvFile } from "../utils";
|
||||
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types";
|
||||
import {
|
||||
TMappingConfidence,
|
||||
autoMapCsvSourceFields,
|
||||
parseCSVColumnsToFields,
|
||||
titleizeFromFileName,
|
||||
validateCsvFile,
|
||||
} from "../utils";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
|
||||
interface CsvConnectorUIProps {
|
||||
@@ -16,8 +22,8 @@ interface CsvConnectorUIProps {
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
onSourceFieldsChange: (fields: TSourceField[]) => void;
|
||||
onLoadSampleCSV: () => void;
|
||||
onParsedDataChange?: (data: Record<string, string>[]) => void;
|
||||
onSuggestConnectorName?: (name: string) => void;
|
||||
}
|
||||
|
||||
export function CsvConnectorUI({
|
||||
@@ -25,14 +31,29 @@ export function CsvConnectorUI({
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
onSourceFieldsChange,
|
||||
onLoadSampleCSV,
|
||||
onParsedDataChange,
|
||||
}: CsvConnectorUIProps) {
|
||||
onSuggestConnectorName,
|
||||
}: Readonly<CsvConnectorUIProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
|
||||
const [csvTotalRows, setCsvTotalRows] = useState(0);
|
||||
const [showMapping, setShowMapping] = useState(false);
|
||||
const [csvError, setCsvError] = useState("");
|
||||
const [confidenceByTargetId, setConfidenceByTargetId] = useState<Record<string, TMappingConfidence>>({});
|
||||
const [previewOpen, setPreviewOpen] = useState(true);
|
||||
const [sampleRow, setSampleRow] = useState<Record<string, string> | undefined>(undefined);
|
||||
|
||||
const userEditedSourceNameRef = useRef(false);
|
||||
const lastAutoSourceNameRef = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const sourceNameMapping = mappings.find((m) => m.targetFieldId === "source_name");
|
||||
const current = sourceNameMapping?.staticValue ?? sourceNameMapping?.sourceFieldId;
|
||||
if (lastAutoSourceNameRef.current !== undefined && current !== lastAutoSourceNameRef.current) {
|
||||
userEditedSourceNameRef.current = true;
|
||||
}
|
||||
}, [mappings]);
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target?.files?.[0];
|
||||
@@ -41,6 +62,61 @@ export function CsvConnectorUI({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserMappingsChange = (newMappings: TFieldMapping[]) => {
|
||||
const oldByTarget = new Map(mappings.map((m) => [m.targetFieldId, m]));
|
||||
const newByTarget = new Map(newMappings.map((m) => [m.targetFieldId, m]));
|
||||
const changedIds = new Set<string>();
|
||||
for (const [id, m] of newByTarget) {
|
||||
const prev = oldByTarget.get(id);
|
||||
if (!prev || prev.sourceFieldId !== m.sourceFieldId || prev.staticValue !== m.staticValue) {
|
||||
changedIds.add(id);
|
||||
}
|
||||
}
|
||||
for (const id of oldByTarget.keys()) {
|
||||
if (!newByTarget.has(id)) changedIds.add(id);
|
||||
}
|
||||
|
||||
if (changedIds.size > 0) {
|
||||
setConfidenceByTargetId((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of changedIds) delete next[id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
onMappingsChange(newMappings);
|
||||
};
|
||||
|
||||
const applyAutoMapping = (fields: TSourceField[], sampleRow: Record<string, string>, fileName: string) => {
|
||||
const { mappings: autoMappings, confidence } = autoMapCsvSourceFields({
|
||||
sourceFields: fields,
|
||||
sampleRow,
|
||||
fileName,
|
||||
});
|
||||
|
||||
const autoSourceNameStatic = autoMappings.find((m) => m.targetFieldId === "source_name")?.staticValue;
|
||||
|
||||
if (userEditedSourceNameRef.current) {
|
||||
const existingSourceName = mappings.find((m) => m.targetFieldId === "source_name");
|
||||
if (existingSourceName) {
|
||||
const filtered = autoMappings.filter((m) => m.targetFieldId !== "source_name");
|
||||
onMappingsChange([...filtered, existingSourceName]);
|
||||
const nextConfidence = { ...confidence };
|
||||
delete nextConfidence.source_name;
|
||||
setConfidenceByTargetId(nextConfidence);
|
||||
} else {
|
||||
onMappingsChange(autoMappings);
|
||||
setConfidenceByTargetId(confidence);
|
||||
}
|
||||
} else {
|
||||
onMappingsChange(autoMappings);
|
||||
setConfidenceByTargetId(confidence);
|
||||
}
|
||||
|
||||
lastAutoSourceNameRef.current = autoSourceNameStatic;
|
||||
onSuggestConnectorName?.(titleizeFromFileName(fileName));
|
||||
};
|
||||
|
||||
const processCSVFile = (file: File) => {
|
||||
setCsvError("");
|
||||
|
||||
@@ -73,6 +149,7 @@ export function CsvConnectorUI({
|
||||
];
|
||||
setCsvFile(file);
|
||||
setCsvPreview(preview);
|
||||
setCsvTotalRows(validRecords.length);
|
||||
|
||||
const fields: TSourceField[] = headers.map((header) => ({
|
||||
id: header,
|
||||
@@ -82,6 +159,10 @@ export function CsvConnectorUI({
|
||||
}));
|
||||
onSourceFieldsChange(fields);
|
||||
onParsedDataChange?.(validRecords);
|
||||
setSampleRow(validRecords[0]);
|
||||
|
||||
applyAutoMapping(fields, validRecords[0], file.name);
|
||||
|
||||
setShowMapping(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t("common.failed_to_parse_csv");
|
||||
@@ -106,67 +187,106 @@ export function CsvConnectorUI({
|
||||
};
|
||||
|
||||
const handleLoadSample = () => {
|
||||
onLoadSampleCSV();
|
||||
const fields = parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS);
|
||||
const synthSampleRow = Object.fromEntries(fields.map((f) => [f.id, f.sampleValue ?? ""])) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
onSourceFieldsChange(fields);
|
||||
onParsedDataChange?.([]);
|
||||
setSampleRow(synthSampleRow);
|
||||
setCsvPreview([fields.map((f) => f.id), fields.map((f) => f.sampleValue ?? "")]);
|
||||
setCsvTotalRows(1);
|
||||
applyAutoMapping(fields, synthSampleRow, "sample-feedback.csv");
|
||||
setShowMapping(true);
|
||||
};
|
||||
|
||||
if (showMapping && sourceFields.length > 0) {
|
||||
const sourceLabel = csvFile?.name ?? t("workspace.unify.csv_sample_label");
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{csvFile && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-800">{csvFile.name}</span>
|
||||
<Badge text={`${csvPreview.length - 1} rows`} type="gray" size="tiny" />
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCsvFile(null);
|
||||
setCsvPreview([]);
|
||||
setCsvError("");
|
||||
setShowMapping(false);
|
||||
onSourceFieldsChange([]);
|
||||
onParsedDataChange?.([]);
|
||||
}}>
|
||||
{t("workspace.unify.change_file")}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-800">{sourceLabel}</span>
|
||||
<Badge
|
||||
text={t("workspace.unify.csv_rows_count", { count: csvTotalRows })}
|
||||
type="gray"
|
||||
size="tiny"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCsvFile(null);
|
||||
setCsvPreview([]);
|
||||
setCsvTotalRows(0);
|
||||
setCsvError("");
|
||||
setShowMapping(false);
|
||||
setConfidenceByTargetId({});
|
||||
setSampleRow(undefined);
|
||||
userEditedSourceNameRef.current = false;
|
||||
lastAutoSourceNameRef.current = undefined;
|
||||
onSourceFieldsChange([]);
|
||||
onParsedDataChange?.([]);
|
||||
}}>
|
||||
{t("workspace.unify.change_file")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{csvPreview.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{csvPreview[0]?.map((header, i) => (
|
||||
<th key={`${header}-${i}`} className="px-3 py-2 text-left font-medium text-slate-700">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{csvPreview.slice(1, 4).map((row, rowIndex) => (
|
||||
<tr key={`${rowIndex}-${row.join("|")}`} className="border-t border-slate-100">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={`${csvPreview[0]?.[cellIndex] ?? cellIndex}-${cellIndex}`}
|
||||
className="px-3 py-2 text-slate-600">
|
||||
{cell || <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1 bg-slate-50 px-3 py-2 text-left text-xs font-medium text-slate-700 hover:bg-slate-100">
|
||||
{previewOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-3 w-3" />
|
||||
)}
|
||||
{t("workspace.unify.csv_data_preview")}
|
||||
{(() => {
|
||||
const visible = Math.min(3, Math.max(csvPreview.length - 1, 0));
|
||||
if (visible >= csvTotalRows) return null;
|
||||
return (
|
||||
<span className="text-slate-500">
|
||||
({t("workspace.unify.showing_rows", { visible, total: csvTotalRows })})
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
{previewOpen && (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{csvPreview[0]?.map((header, i) => (
|
||||
<th
|
||||
key={`${header}-${i}`}
|
||||
className="px-3 py-2 text-left font-medium text-slate-700">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{csvPreview.slice(1, 4).map((row, rowIndex) => (
|
||||
<tr key={`${rowIndex}-${row.join("|")}`} className="border-t border-slate-100">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td
|
||||
key={`${csvPreview[0]?.[cellIndex] ?? cellIndex}-${cellIndex}`}
|
||||
className="px-3 py-2 text-slate-600">
|
||||
{cell || <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{csvPreview.length > 4 && (
|
||||
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
|
||||
{t("workspace.unify.showing_rows", { count: csvPreview.length - 1 })}
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -174,9 +294,13 @@ export function CsvConnectorUI({
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={onMappingsChange}
|
||||
onMappingsChange={handleUserMappingsChange}
|
||||
connectorType="csv"
|
||||
confidenceByTargetId={confidenceByTargetId}
|
||||
sampleRow={sampleRow}
|
||||
/>
|
||||
|
||||
<UnmappedColumnsFooter sourceFields={sourceFields} mappings={mappings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -220,3 +344,27 @@ export function CsvConnectorUI({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UnmappedColumnsFooterProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
}
|
||||
|
||||
const UnmappedColumnsFooter = ({ sourceFields, mappings }: Readonly<UnmappedColumnsFooterProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const claimed = new Set(mappings.map((m) => m.sourceFieldId).filter((id): id is string => Boolean(id)));
|
||||
const unmapped = sourceFields.filter((f) => !claimed.has(f.id));
|
||||
if (unmapped.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||
<p className="font-medium">
|
||||
{t("workspace.unify.csv_unmapped_columns", {
|
||||
count: unmapped.length,
|
||||
columns: unmapped.map((c) => c.name).join(", "),
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-0.5 text-slate-500">{t("workspace.unify.csv_unmapped_columns_explainer")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,13 @@ import { ArrowUpFromLineIcon, Loader2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TConnectorFieldMapping } from "@formbricks/types/connector";
|
||||
import { importCsvDataAction } from "@/lib/connector/actions";
|
||||
import {
|
||||
formatCsvMissingMappedSourceColumns,
|
||||
getMissingCsvMappedSourceColumns,
|
||||
getMissingRequiredCsvSourceColumns,
|
||||
} from "@/lib/connector/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
@@ -18,7 +24,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { createFeedbackCSVDataSchema } from "../types";
|
||||
import { createFeedbackCSVDataSchema, getTranslatedConnectorError } from "../types";
|
||||
import { validateCsvFile } from "../utils";
|
||||
|
||||
interface CsvImportModalProps {
|
||||
@@ -26,6 +32,7 @@ interface CsvImportModalProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
connectorId: string;
|
||||
workspaceId: string;
|
||||
fieldMappings: TConnectorFieldMapping[];
|
||||
onOpenEditConnector?: () => void;
|
||||
}
|
||||
|
||||
@@ -34,6 +41,7 @@ export function CsvImportModal({
|
||||
onOpenChange,
|
||||
connectorId,
|
||||
workspaceId,
|
||||
fieldMappings,
|
||||
onOpenEditConnector,
|
||||
}: CsvImportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -64,6 +72,29 @@ export function CsvImportModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const missingMappedColumns = getMissingCsvMappedSourceColumns(
|
||||
fieldMappings,
|
||||
Object.keys(result.data[0] ?? {})
|
||||
);
|
||||
if (missingMappedColumns.length > 0) {
|
||||
const missingRequiredSourceColumns = getMissingRequiredCsvSourceColumns(
|
||||
fieldMappings,
|
||||
Object.keys(result.data[0] ?? {})
|
||||
);
|
||||
const missingSourceColumns =
|
||||
missingRequiredSourceColumns.length > 0
|
||||
? missingRequiredSourceColumns.join(", ")
|
||||
: [...new Set(missingMappedColumns.map(({ sourceFieldId }) => sourceFieldId))].join(", ");
|
||||
|
||||
setCsvError(
|
||||
t("workspace.unify.csv_saved_mapping_missing_columns", {
|
||||
columns: missingSourceColumns,
|
||||
mappings: formatCsvMissingMappedSourceColumns(missingMappedColumns),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvFile(file);
|
||||
setParsedData(result.data);
|
||||
setRowCount(result.data.length);
|
||||
@@ -111,7 +142,7 @@ export function CsvImportModal({
|
||||
setRowCount(0);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,9 +162,9 @@ export function CsvImportModal({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Alert variant="info" size="small">
|
||||
<p className="rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
||||
{t("workspace.unify.csv_import_duplicate_warning")}
|
||||
</Alert>
|
||||
</p>
|
||||
|
||||
{csvError && (
|
||||
<Alert variant="error" size="small">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -32,6 +33,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import {
|
||||
CSV_HIDDEN_STATIC_MAPPINGS,
|
||||
CSV_PROTECTED_TARGET_IDS,
|
||||
SAMPLE_CSV_COLUMNS,
|
||||
TFieldMapping,
|
||||
TFormbricksConnectorForm,
|
||||
@@ -41,7 +44,7 @@ import {
|
||||
getTranslatedConnectorError,
|
||||
} from "../types";
|
||||
import {
|
||||
areAllRequiredFieldsMapped,
|
||||
areAllRequiredCsvFieldsMapped,
|
||||
isConnectorNameValid,
|
||||
parseCSVColumnsToFields,
|
||||
toggleQuestionId,
|
||||
@@ -124,8 +127,8 @@ export const EditConnectorModal = ({
|
||||
];
|
||||
setSourceFields(
|
||||
columnsFromMappings.length > 0
|
||||
? parseCSVColumnsToFields(columnsFromMappings.join(","))
|
||||
: parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS)
|
||||
? parseCSVColumnsToFields(columnsFromMappings.join(","), { includeSampleValues: false })
|
||||
: parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS, { includeSampleValues: false })
|
||||
);
|
||||
setMappings(
|
||||
connector.fieldMappings.map((m) => ({
|
||||
@@ -192,13 +195,27 @@ export const EditConnectorModal = ({
|
||||
|
||||
const handleUpdateCsvConnector = async () => {
|
||||
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
|
||||
|
||||
const requiredCheck = areAllRequiredCsvFieldsMapped(mappings);
|
||||
if (!requiredCheck.valid) {
|
||||
toast.error(
|
||||
t("workspace.unify.csv_required_fields_missing", { fields: requiredCheck.missing.join(", ") })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
const userMappings = mappings.filter((m) =>
|
||||
CSV_PROTECTED_TARGET_IDS.every((id) => m.targetFieldId !== id)
|
||||
);
|
||||
const fieldMappings = [...userMappings, ...CSV_HIDDEN_STATIC_MAPPINGS];
|
||||
|
||||
const success = await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: csvConnectorName.trim(),
|
||||
surveyMappings: undefined,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
fieldMappings,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
if (success) {
|
||||
@@ -227,7 +244,7 @@ export const EditConnectorModal = ({
|
||||
}
|
||||
|
||||
if (connector.type === "csv") {
|
||||
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredFieldsMapped(mappings);
|
||||
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredCsvFieldsMapped(mappings).valid;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,358 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ClockIcon, EraserIcon, PencilIcon, SparklesIcon, TextCursorInputIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { TFieldMapping, TSourceField, TTargetField } from "../types";
|
||||
|
||||
interface DraggableSourceFieldProps {
|
||||
field: TSourceField;
|
||||
isMapped: boolean;
|
||||
export type TAutoMapState = "high" | "medium" | "low";
|
||||
|
||||
interface AutoMappedBadgeProps {
|
||||
sourceColumn?: string;
|
||||
}
|
||||
|
||||
const getSourceFieldStateClass = (isDragging: boolean, isMapped: boolean): string => {
|
||||
if (isDragging) return "border-brand-dark bg-slate-100 opacity-50";
|
||||
if (isMapped) return "border-green-300 bg-green-50 text-green-800";
|
||||
return "border-slate-200 bg-white hover:border-slate-300";
|
||||
};
|
||||
|
||||
export const DraggableSourceField = ({ field, isMapped }: DraggableSourceFieldProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${getSourceFieldStateClass(isDragging, isMapped)}`}>
|
||||
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
|
||||
<div className="flex-1 truncate">
|
||||
<span className="font-medium">{field.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
|
||||
</div>
|
||||
{field.sampleValue && (
|
||||
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getMappingStateClass = (isActive: boolean, hasMapping: unknown): string => {
|
||||
if (isActive) return "border-brand-dark bg-slate-100";
|
||||
if (hasMapping) return "border-green-300 bg-green-50";
|
||||
return "border-dashed border-slate-300 bg-slate-50";
|
||||
};
|
||||
|
||||
interface RemoveMappingButtonProps {
|
||||
onClick: () => void;
|
||||
variant: "green" | "blue";
|
||||
}
|
||||
|
||||
const RemoveMappingButton = ({ onClick, variant }: RemoveMappingButtonProps) => {
|
||||
const colorClass = variant === "green" ? "hover:bg-green-100" : "hover:bg-blue-100";
|
||||
const iconClass = variant === "green" ? "text-green-600" : "text-blue-600";
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={`ml-1 rounded p-0.5 ${colorClass}`}>
|
||||
<XIcon className={`h-3 w-3 ${iconClass}`} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface EnumTargetFieldContentProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const EnumTargetFieldContent = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
t,
|
||||
}: EnumTargetFieldContentProps) => {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
<span className="text-xs text-slate-400">{t("workspace.unify.enum")}</span>
|
||||
</div>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
</div>
|
||||
) : (
|
||||
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
|
||||
<SelectTrigger className="h-8 w-full bg-white">
|
||||
<SelectValue placeholder={t("workspace.unify.select_a_value")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.enumValues?.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StringTargetFieldContentProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
hasMapping: unknown;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const StringTargetFieldContent = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
hasMapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
t,
|
||||
}: StringTargetFieldContentProps) => {
|
||||
const [isEditingStatic, setIsEditingStatic] = useState(false);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
</div>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapping?.staticValue && !mappedSourceField && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
= “{mapping.staticValue}”
|
||||
</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingStatic && !hasMapping && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
placeholder={
|
||||
field.exampleStaticValues
|
||||
? `e.g., ${field.exampleStaticValues[0]}`
|
||||
: t("workspace.unify.enter_value")
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && customValue.trim()) {
|
||||
onStaticValueChange(customValue.trim());
|
||||
setCustomValue("");
|
||||
setIsEditingStatic(false);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setCustomValue("");
|
||||
setIsEditingStatic(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (customValue.trim()) {
|
||||
onStaticValueChange(customValue.trim());
|
||||
setCustomValue("");
|
||||
}
|
||||
setIsEditingStatic(false);
|
||||
}}
|
||||
className="rounded p-1 text-slate-500 hover:bg-slate-200">
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMapping && !isEditingStatic && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">{t("workspace.unify.drop_field_or")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingStatic(true)}
|
||||
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
{t("workspace.unify.set_value")}
|
||||
</button>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
{field.exampleStaticValues.slice(0, 3).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => onStaticValueChange(val)}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||
{val}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DroppableTargetFieldProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
isOver?: boolean;
|
||||
}
|
||||
|
||||
export const DroppableTargetField = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
isOver,
|
||||
}: DroppableTargetFieldProps) => {
|
||||
export const AutoMappedBadge = ({ sourceColumn }: AutoMappedBadgeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
});
|
||||
|
||||
const isActive = isOver || isOverCurrent;
|
||||
const hasMapping = mappedSourceField || mapping?.staticValue;
|
||||
const containerClass = cn(
|
||||
"flex items-center gap-2 rounded-md border p-2 text-sm transition-colors",
|
||||
getMappingStateClass(!!isActive, hasMapping)
|
||||
const className = cn(
|
||||
"ml-1 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
"bg-indigo-50 text-indigo-700"
|
||||
);
|
||||
const label = t("workspace.unify.csv_auto_mapped");
|
||||
|
||||
if (field.type === "enum" && field.enumValues) {
|
||||
if (!sourceColumn) {
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<EnumTargetFieldContent
|
||||
field={field}
|
||||
mappedSourceField={mappedSourceField}
|
||||
mapping={mapping}
|
||||
onRemoveMapping={onRemoveMapping}
|
||||
onStaticValueChange={onStaticValueChange}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
<span className={className}>
|
||||
<SparklesIcon className="h-3 w-3" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "string") {
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<StringTargetFieldContent
|
||||
field={field}
|
||||
mappedSourceField={mappedSourceField}
|
||||
mapping={mapping}
|
||||
hasMapping={hasMapping}
|
||||
onRemoveMapping={onRemoveMapping}
|
||||
onStaticValueChange={onStaticValueChange}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TooltipRenderer tooltipContent={t("workspace.unify.csv_auto_mapped_tooltip", { column: sourceColumn })}>
|
||||
<span className={className}>
|
||||
<SparklesIcon className="h-3 w-3" />
|
||||
{label}
|
||||
</span>
|
||||
</TooltipRenderer>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to get display label for static values
|
||||
const getStaticValueLabel = (value: string) => {
|
||||
if (value === "$now") return t("workspace.unify.feedback_date");
|
||||
return value;
|
||||
const SENTINEL = {
|
||||
COLUMN_PREFIX: "__col__:",
|
||||
ENUM_PREFIX: "__enum__:",
|
||||
STATIC_NOW: "__static_now__",
|
||||
EDIT_FIXED: "__edit_fixed__",
|
||||
CLEAR: "__clear__",
|
||||
} as const;
|
||||
|
||||
const GROUP_LABEL_CLASS = "px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-500";
|
||||
const ACTION_ITEM_CLASS = "text-slate-700 focus:bg-slate-50";
|
||||
const ACTION_ICON_CLASS = "h-3.5 w-3.5 text-slate-500";
|
||||
|
||||
interface FormTargetFieldProps {
|
||||
field: TTargetField;
|
||||
mapping: TFieldMapping | null;
|
||||
sourceFields: TSourceField[];
|
||||
allMappings: TFieldMapping[];
|
||||
targetNameById: Record<string, string>;
|
||||
onChange: (next: TFieldMapping | null) => void;
|
||||
autoMapState?: TAutoMapState;
|
||||
autoMapSourceColumn?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export const FormTargetField = ({
|
||||
field,
|
||||
mapping,
|
||||
sourceFields,
|
||||
allMappings,
|
||||
targetNameById,
|
||||
onChange,
|
||||
autoMapState,
|
||||
autoMapSourceColumn,
|
||||
preview,
|
||||
}: FormTargetFieldProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isEditingFixed, setIsEditingFixed] = useState(false);
|
||||
const [draftFixedValue, setDraftFixedValue] = useState("");
|
||||
|
||||
const hasMapping = Boolean(mapping?.sourceFieldId || mapping?.staticValue);
|
||||
const isEnum = field.type === "enum" && Boolean(field.enumValues?.length);
|
||||
const isTimestamp = field.type === "timestamp";
|
||||
|
||||
const otherUsageByColumn = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const m of allMappings) {
|
||||
if (!m.sourceFieldId) continue;
|
||||
if (m.targetFieldId === field.id) continue;
|
||||
map[m.sourceFieldId] = targetNameById[m.targetFieldId] ?? m.targetFieldId;
|
||||
}
|
||||
return map;
|
||||
}, [allMappings, field.id, targetNameById]);
|
||||
|
||||
const selectValue = useMemo(() => {
|
||||
if (mapping?.sourceFieldId) return `${SENTINEL.COLUMN_PREFIX}${mapping.sourceFieldId}`;
|
||||
if (mapping?.staticValue === "$now") return SENTINEL.STATIC_NOW;
|
||||
if (isEnum && mapping?.staticValue) return `${SENTINEL.ENUM_PREFIX}${mapping.staticValue}`;
|
||||
if (mapping?.staticValue !== undefined && mapping?.staticValue !== "") {
|
||||
return SENTINEL.EDIT_FIXED;
|
||||
}
|
||||
return "";
|
||||
}, [mapping, isEnum]);
|
||||
|
||||
const selectDisplayValue = useMemo(() => {
|
||||
if (mapping?.sourceFieldId) {
|
||||
return sourceFields.find((sourceField) => sourceField.id === mapping.sourceFieldId)?.name;
|
||||
}
|
||||
if (mapping?.staticValue === "$now") return t("workspace.unify.csv_now_label");
|
||||
if (isEnum && mapping?.staticValue) return mapping.staticValue;
|
||||
if (mapping?.staticValue !== undefined && mapping?.staticValue !== "") {
|
||||
return t("workspace.unify.csv_fixed_value_label", {
|
||||
value: truncate(mapping.staticValue, 40),
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}, [isEnum, mapping, sourceFields, t]);
|
||||
|
||||
const openFixedValueEditor = () => {
|
||||
setDraftFixedValue(mapping?.staticValue && mapping.staticValue !== "$now" ? mapping.staticValue : "");
|
||||
setIsEditingFixed(true);
|
||||
};
|
||||
|
||||
const handleSelectChange = (value: string) => {
|
||||
if (value.startsWith(SENTINEL.COLUMN_PREFIX)) {
|
||||
onChange({
|
||||
targetFieldId: field.id,
|
||||
sourceFieldId: value.slice(SENTINEL.COLUMN_PREFIX.length),
|
||||
});
|
||||
setIsEditingFixed(false);
|
||||
return;
|
||||
}
|
||||
if (value === SENTINEL.STATIC_NOW) {
|
||||
onChange({ targetFieldId: field.id, staticValue: "$now" });
|
||||
setIsEditingFixed(false);
|
||||
return;
|
||||
}
|
||||
if (value.startsWith(SENTINEL.ENUM_PREFIX)) {
|
||||
onChange({
|
||||
targetFieldId: field.id,
|
||||
staticValue: value.slice(SENTINEL.ENUM_PREFIX.length),
|
||||
});
|
||||
setIsEditingFixed(false);
|
||||
return;
|
||||
}
|
||||
if (value === SENTINEL.EDIT_FIXED) {
|
||||
openFixedValueEditor();
|
||||
return;
|
||||
}
|
||||
if (value === SENTINEL.CLEAR) {
|
||||
onChange(null);
|
||||
setIsEditingFixed(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFixed = () => {
|
||||
const trimmed = draftFixedValue.trim();
|
||||
if (trimmed) {
|
||||
onChange({ targetFieldId: field.id, staticValue: trimmed });
|
||||
} else if (!field.required) {
|
||||
onChange(null);
|
||||
}
|
||||
setIsEditingFixed(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
<span className="text-xs text-slate-400">({field.type})</span>
|
||||
</div>
|
||||
<div className="rounded-md border border-slate-200 bg-white p-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
{isEnum && <span className="text-xs text-slate-400">{t("workspace.unify.enum")}</span>}
|
||||
{hasMapping && autoMapState && <AutoMappedBadge sourceColumn={autoMapSourceColumn} />}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{field.description}</p>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
<div className="mt-2">
|
||||
{isEditingFixed ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
value={draftFixedValue}
|
||||
onChange={(e) => setDraftFixedValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSaveFixed();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setIsEditingFixed(false);
|
||||
}
|
||||
}}
|
||||
placeholder={t("workspace.unify.set_value")}
|
||||
className="h-9"
|
||||
/>
|
||||
<Button size="sm" onClick={handleSaveFixed}>
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setIsEditingFixed(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapping?.staticValue && !mappedSourceField && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
= {getStaticValueLabel(mapping.staticValue)}
|
||||
</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMapping && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">{t("workspace.unify.drop_a_field_here")}</span>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
{field.exampleStaticValues.map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => onStaticValueChange(val)}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||
{getStaticValueLabel(val)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Select value={selectValue} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger className="h-9 w-full bg-white">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isEnum
|
||||
? t("workspace.unify.select_a_value")
|
||||
: t("workspace.unify.csv_pick_column_placeholder")
|
||||
}>
|
||||
{selectDisplayValue}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isEnum &&
|
||||
field.enumValues?.map((enumValue) => (
|
||||
<SelectItem key={enumValue} value={`${SENTINEL.ENUM_PREFIX}${enumValue}`}>
|
||||
{enumValue}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!isEnum && sourceFields.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className={GROUP_LABEL_CLASS}>
|
||||
{t("workspace.unify.csv_columns")}
|
||||
</SelectLabel>
|
||||
{sourceFields.map((column) => {
|
||||
const otherUsage = otherUsageByColumn[column.id];
|
||||
const sampleValue = column.sampleValue?.trim();
|
||||
const sampleLabel = sampleValue
|
||||
? t("workspace.unify.csv_first_value", { value: truncate(sampleValue, 48) })
|
||||
: undefined;
|
||||
return (
|
||||
<SelectItem
|
||||
key={column.id}
|
||||
value={`${SENTINEL.COLUMN_PREFIX}${column.id}`}
|
||||
textValue={column.name}
|
||||
className="py-2">
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-slate-900">{column.name}</span>
|
||||
{otherUsage && (
|
||||
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-xs font-normal text-slate-500">
|
||||
{t("workspace.unify.csv_column_used_by", { target: otherUsage })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{sampleLabel && (
|
||||
<span className="mt-0.5 truncate text-xs font-normal text-slate-400">
|
||||
{sampleLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{isTimestamp && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectItem value={SENTINEL.STATIC_NOW} className={ACTION_ITEM_CLASS}>
|
||||
<span className="inline-flex items-center gap-2 font-normal">
|
||||
<ClockIcon className={ACTION_ICON_CLASS} />
|
||||
{t("workspace.unify.csv_now_label")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{!isEnum && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectItem value={SENTINEL.EDIT_FIXED} className={ACTION_ITEM_CLASS}>
|
||||
<span className="inline-flex items-center gap-2 font-normal">
|
||||
<TextCursorInputIcon className="h-3.5 w-3.5 text-indigo-500" />
|
||||
{mapping?.staticValue && mapping.staticValue !== "$now"
|
||||
? t("workspace.unify.csv_fixed_value_label", {
|
||||
value: truncate(mapping.staticValue, 40),
|
||||
})
|
||||
: t("workspace.unify.csv_fixed_value_action")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{!field.required && hasMapping && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectItem value={SENTINEL.CLEAR} className="text-slate-700 focus:bg-orange-50">
|
||||
<span className="inline-flex items-center gap-2 font-normal">
|
||||
<EraserIcon className="h-3.5 w-3.5 text-orange-500" />
|
||||
{t("workspace.unify.clear_mapping")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{!isEnum && mapping?.staticValue && mapping.staticValue !== "$now" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={openFixedValueEditor}
|
||||
aria-label={t("workspace.unify.csv_fixed_value_action")}
|
||||
className="shrink-0">
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{preview && <p className="mt-2 text-xs text-slate-500">{preview}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const truncate = (value: string, max: number): string =>
|
||||
value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
||||
|
||||
@@ -1,146 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
|
||||
import { useState } from "react";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorType } from "@formbricks/types/connector";
|
||||
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField } from "../types";
|
||||
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
|
||||
import { TConnectorType, THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
|
||||
import { routeResponseValueTarget } from "@/lib/connector/utils";
|
||||
import { CSV_FIELD_GROUPS, CSV_TARGET_FIELDS, TFieldMapping, TSourceField, TTargetField } from "../types";
|
||||
import { TMappingConfidence } from "../utils";
|
||||
import { FormTargetField, TAutoMapState } from "./mapping-field";
|
||||
|
||||
interface MappingUIProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
connectorType: TConnectorType;
|
||||
confidenceByTargetId?: Record<string, TMappingConfidence>;
|
||||
sampleRow?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorType }: MappingUIProps) {
|
||||
const toAutoMapState = (confidence?: TMappingConfidence): TAutoMapState | undefined => {
|
||||
if (confidence === "high" || confidence === "medium" || confidence === "low") return confidence;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export function MappingUI({
|
||||
sourceFields,
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
connectorType,
|
||||
confidenceByTargetId,
|
||||
sampleRow,
|
||||
}: MappingUIProps) {
|
||||
switch (connectorType) {
|
||||
case "csv":
|
||||
return (
|
||||
<CsvMappingForm
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={onMappingsChange}
|
||||
confidenceByTargetId={confidenceByTargetId}
|
||||
sampleRow={sampleRow}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface CsvMappingFormProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
confidenceByTargetId?: Record<string, TMappingConfidence>;
|
||||
sampleRow?: Record<string, string>;
|
||||
}
|
||||
|
||||
const CsvMappingForm = ({
|
||||
sourceFields,
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
confidenceByTargetId,
|
||||
sampleRow,
|
||||
}: CsvMappingFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
|
||||
const fieldsById = new Map(CSV_TARGET_FIELDS.map((f) => [f.id, f]));
|
||||
const targetNameById = useMemo(() => Object.fromEntries(CSV_TARGET_FIELDS.map((f) => [f.id, f.name])), []);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
const upsertMapping = (next: TFieldMapping) => {
|
||||
const filtered = mappings.filter((m) => m.targetFieldId !== next.targetFieldId);
|
||||
onMappingsChange([...filtered, next]);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const sourceFieldId = active.id as string;
|
||||
const targetFieldId = over.id as string;
|
||||
|
||||
const newMappings = mappings.filter(
|
||||
(m) => m.sourceFieldId !== sourceFieldId && m.targetFieldId !== targetFieldId
|
||||
);
|
||||
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (targetFieldId: string) => {
|
||||
const removeMapping = (targetFieldId: string) => {
|
||||
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
|
||||
};
|
||||
|
||||
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
|
||||
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
|
||||
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
|
||||
const handleChange = (targetFieldId: string, next: TFieldMapping | null) => {
|
||||
if (next === null) removeMapping(targetFieldId);
|
||||
else upsertMapping(next);
|
||||
};
|
||||
|
||||
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
|
||||
const responseValueMapping = mappings.find((m) => m.targetFieldId === "response_value");
|
||||
const fieldTypeMapping = mappings.find((m) => m.targetFieldId === "field_type");
|
||||
const responseValuePreview = computeResponseValuePreview({
|
||||
responseValueMapping,
|
||||
fieldTypeMapping,
|
||||
sampleRow,
|
||||
sourceFields,
|
||||
t,
|
||||
});
|
||||
|
||||
const getMappingForTarget = (targetFieldId: string) => {
|
||||
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
|
||||
const renderField = (target: TTargetField) => {
|
||||
const mapping = mappings.find((m) => m.targetFieldId === target.id) ?? null;
|
||||
const autoMapState = toAutoMapState(confidenceByTargetId?.[target.id]);
|
||||
const sourceColumnName = mapping?.sourceFieldId
|
||||
? sourceFields.find((s) => s.id === mapping.sourceFieldId)?.name
|
||||
: undefined;
|
||||
const isResponseValue = target.id === "response_value";
|
||||
|
||||
return (
|
||||
<FormTargetField
|
||||
key={target.id}
|
||||
field={target}
|
||||
mapping={mapping}
|
||||
sourceFields={sourceFields}
|
||||
allMappings={mappings}
|
||||
targetNameById={targetNameById}
|
||||
onChange={(next) => handleChange(target.id, next)}
|
||||
autoMapState={autoMapState}
|
||||
autoMapSourceColumn={sourceColumnName}
|
||||
preview={isResponseValue ? responseValuePreview : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getMappedSourceField = (targetFieldId: string) => {
|
||||
const mapping = getMappingForTarget(targetFieldId);
|
||||
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
|
||||
};
|
||||
|
||||
const isSourceFieldMapped = (sourceFieldId: string) =>
|
||||
mappings.some((m) => m.sourceFieldId === sourceFieldId);
|
||||
|
||||
const activeField = activeId ? getSourceFieldById(activeId) : null;
|
||||
const renderGroup = (ids: readonly string[]) =>
|
||||
ids
|
||||
.map((id) => fieldsById.get(id))
|
||||
.filter((f): f is TTargetField => Boolean(f))
|
||||
.map(renderField);
|
||||
|
||||
return (
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Source Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">
|
||||
{connectorType === "csv" ? t("workspace.unify.csv_columns") : t("workspace.unify.source_fields")}
|
||||
</h4>
|
||||
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">
|
||||
{connectorType === "csv"
|
||||
? t("workspace.unify.click_load_sample_csv")
|
||||
: t("workspace.unify.no_source_fields_loaded")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sourceFields.map((field) => (
|
||||
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Target Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.feedback_record_fields")}
|
||||
</h4>
|
||||
|
||||
{/* Required Fields */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{t("workspace.unify.required")}
|
||||
</p>
|
||||
{requiredFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
field={field}
|
||||
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||
mapping={getMappingForTarget(field.id)}
|
||||
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Optional Fields */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{t("workspace.unify.optional")}
|
||||
</p>
|
||||
{optionalFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
field={field}
|
||||
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||
mapping={getMappingForTarget(field.id)}
|
||||
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-800">{t("workspace.unify.csv_basic_required")}</p>
|
||||
<p className="text-xs text-slate-500">{t("workspace.unify.csv_basic_required_hint")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">{renderGroup(CSV_FIELD_GROUPS.basic)}</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeField ? (
|
||||
<div className="rounded-md border border-brand-dark bg-white p-2 text-sm shadow-lg">
|
||||
<span className="font-medium">{activeField.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-800">{t("workspace.unify.csv_source_context")}</p>
|
||||
<p className="text-xs text-slate-500">{t("workspace.unify.csv_source_context_hint")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">{renderGroup(CSV_FIELD_GROUPS.sourceContext)}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((v) => !v)}
|
||||
className="flex w-full items-start gap-2 rounded text-left">
|
||||
<span className="mt-0.5 text-slate-500">
|
||||
{advancedOpen ? (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-slate-800">
|
||||
{t("workspace.unify.csv_advanced")}
|
||||
</span>
|
||||
<span className="block text-xs text-slate-500">{t("workspace.unify.csv_advanced_hint")}</span>
|
||||
</span>
|
||||
</button>
|
||||
{advancedOpen && <div className="space-y-2">{renderGroup(CSV_FIELD_GROUPS.advanced)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ComputePreviewArgs {
|
||||
responseValueMapping: TFieldMapping | undefined;
|
||||
fieldTypeMapping: TFieldMapping | undefined;
|
||||
sampleRow: Record<string, string> | undefined;
|
||||
sourceFields: TSourceField[];
|
||||
t: ReturnType<typeof useTranslation>["t"];
|
||||
}
|
||||
|
||||
const computeResponseValuePreview = ({
|
||||
responseValueMapping,
|
||||
fieldTypeMapping,
|
||||
sampleRow,
|
||||
sourceFields,
|
||||
t,
|
||||
}: ComputePreviewArgs): string | undefined => {
|
||||
if (!responseValueMapping?.sourceFieldId) return undefined;
|
||||
const fieldTypeRaw = fieldTypeMapping?.staticValue ?? "";
|
||||
const parsed = ZHubFieldType.safeParse(fieldTypeRaw);
|
||||
if (!parsed.success) return undefined;
|
||||
const fieldType: THubFieldType = parsed.data;
|
||||
const target = routeResponseValueTarget(fieldType);
|
||||
const sample =
|
||||
sampleRow?.[responseValueMapping.sourceFieldId] ??
|
||||
sourceFields.find((f) => f.id === responseValueMapping.sourceFieldId)?.sampleValue ??
|
||||
"";
|
||||
const localizedTarget = t(`workspace.unify.fields.${target.replace("value_", "")}`);
|
||||
return t("workspace.unify.csv_response_preview", {
|
||||
sample,
|
||||
target: localizedTarget,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface TFieldMapping {
|
||||
staticValue?: string;
|
||||
}
|
||||
|
||||
export const CSV_IMPORT_MISSING_COLUMNS_ERROR_CODE = "CSV_IMPORT_MISSING_COLUMNS";
|
||||
|
||||
export type TTargetFieldType = "string" | "enum" | "timestamp" | "float64" | "boolean" | "jsonb" | "string[]";
|
||||
|
||||
export interface TTargetField {
|
||||
@@ -84,9 +86,9 @@ export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
|
||||
id: "submission_id",
|
||||
name: "Submission ID",
|
||||
type: "string",
|
||||
required: false,
|
||||
required: true,
|
||||
description:
|
||||
"Optional. Map to a stable column (e.g. order_id, ticket_id) to enable idempotent re-imports. Auto-generated UUID per row if unmapped.",
|
||||
"Map to a stable column (e.g. response_id, order_id, ticket_id) to enable idempotent re-imports.",
|
||||
},
|
||||
{
|
||||
id: "source_id",
|
||||
@@ -175,7 +177,43 @@ export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
|
||||
export const CSV_RESPONSE_VALUE_TARGET: TTargetField = {
|
||||
id: "response_value",
|
||||
name: "Response",
|
||||
type: "string",
|
||||
required: true,
|
||||
description:
|
||||
"The user's actual answer or value. We'll store it in the right format (text, number, boolean, or date) based on Field Type.",
|
||||
};
|
||||
|
||||
const CSV_HIDDEN_TARGET_IDS = [
|
||||
"tenant_id",
|
||||
"source_type",
|
||||
"value_text",
|
||||
"value_number",
|
||||
"value_boolean",
|
||||
"value_date",
|
||||
];
|
||||
export const CSV_TARGET_FIELDS: TTargetField[] = [
|
||||
...FEEDBACK_RECORD_FIELDS.filter((f) => CSV_HIDDEN_TARGET_IDS.every((id) => f.id !== id)),
|
||||
CSV_RESPONSE_VALUE_TARGET,
|
||||
];
|
||||
|
||||
export const CSV_FIELD_GROUPS = {
|
||||
basic: ["submission_id", "collected_at", "field_id", "field_label", "field_type", "response_value"],
|
||||
sourceContext: ["source_id", "source_name"],
|
||||
advanced: ["field_group_id", "field_group_label", "language", "user_id", "metadata"],
|
||||
} as const;
|
||||
|
||||
export const CSV_PROTECTED_TARGET_IDS = ["tenant_id", "source_type"] as const;
|
||||
|
||||
export const CSV_HIDDEN_STATIC_MAPPINGS: TFieldMapping[] = [
|
||||
{ sourceFieldId: "", targetFieldId: "source_type", staticValue: "csv" },
|
||||
];
|
||||
|
||||
export const CSV_REQUIRED_UI_FIELDS = ["submission_id", "field_id", "field_type", "response_value"];
|
||||
|
||||
export const SAMPLE_CSV_COLUMNS = "timestamp,response_id,customer_id,rating,feedback_text,category";
|
||||
|
||||
export const MAX_CSV_VALUES = {
|
||||
FILE_SIZE: 2_097_152, // 2MB (2 * 1024 * 1024)
|
||||
@@ -236,6 +274,8 @@ export const getTranslatedConnectorError = (errorCode: string, t: TFunction): st
|
||||
return t("workspace.unify.error_connector_formbricks_mapping_duplicate");
|
||||
case "CONNECTOR_FIELD_MAPPING_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_field_mapping_duplicate");
|
||||
case CSV_IMPORT_MISSING_COLUMNS_ERROR_CODE:
|
||||
return t("workspace.unify.csv_saved_mapping_missing_columns");
|
||||
case "CONNECTOR_NAME_REQUIRED":
|
||||
return t("workspace.unify.error_connector_name_required");
|
||||
case "CONNECTOR_SURVEY_REQUIRED":
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
|
||||
import { CSV_HIDDEN_STATIC_MAPPINGS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
|
||||
import {
|
||||
areAllRequiredFieldsMapped,
|
||||
areAllRequiredCsvFieldsMapped,
|
||||
autoMapCsvSourceFields,
|
||||
getConnectorOptions,
|
||||
inferFieldType,
|
||||
isConnectorNameValid,
|
||||
parseCSVColumnsToFields,
|
||||
titleizeFromFileName,
|
||||
toggleQuestionId,
|
||||
validateCsvFile,
|
||||
} from "./utils";
|
||||
@@ -146,59 +149,265 @@ describe("isConnectorNameValid", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("areAllRequiredFieldsMapped", () => {
|
||||
const requiredMappings: TFieldMapping[] = [
|
||||
{ targetFieldId: "collected_at", sourceFieldId: "ts" },
|
||||
{ targetFieldId: "source_type", staticValue: "csv" },
|
||||
describe("areAllRequiredCsvFieldsMapped", () => {
|
||||
const fullMappings: TFieldMapping[] = [
|
||||
{ targetFieldId: "submission_id", sourceFieldId: "response_id" },
|
||||
{ targetFieldId: "field_id", sourceFieldId: "qid" },
|
||||
{ targetFieldId: "field_label", sourceFieldId: "label" },
|
||||
{ targetFieldId: "field_type", staticValue: "text" },
|
||||
{ targetFieldId: "response_value", sourceFieldId: "answer" },
|
||||
];
|
||||
|
||||
test("returns true when all required fields have a sourceFieldId or staticValue", () => {
|
||||
expect(areAllRequiredFieldsMapped(requiredMappings)).toBe(true);
|
||||
test("returns valid=true and missing=[] when every required UI field is resolved", () => {
|
||||
expect(areAllRequiredCsvFieldsMapped(fullMappings)).toEqual({ valid: true, missing: [] });
|
||||
});
|
||||
|
||||
test("returns false when a required field is missing entirely", () => {
|
||||
const missing = requiredMappings.slice(0, 3);
|
||||
expect(areAllRequiredFieldsMapped(missing)).toBe(false);
|
||||
});
|
||||
test.each(["submission_id", "field_id", "field_type", "response_value"])(
|
||||
"returns valid=false and lists %s when missing",
|
||||
(missingId) => {
|
||||
const partial = fullMappings.filter((m) => m.targetFieldId !== missingId);
|
||||
const result = areAllRequiredCsvFieldsMapped(partial);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.missing).toContain(missingId);
|
||||
}
|
||||
);
|
||||
|
||||
test("returns false when a required mapping has neither sourceFieldId nor staticValue", () => {
|
||||
const incomplete: TFieldMapping[] = [...requiredMappings.slice(0, 3), { targetFieldId: "field_type" }];
|
||||
expect(areAllRequiredFieldsMapped(incomplete)).toBe(false);
|
||||
});
|
||||
|
||||
test("ignores mappings for non-required target fields", () => {
|
||||
const withOptionals: TFieldMapping[] = [
|
||||
...requiredMappings,
|
||||
{ targetFieldId: "tenant_id", sourceFieldId: "tenant" },
|
||||
{ targetFieldId: "unknown_field", sourceFieldId: "anything" },
|
||||
];
|
||||
expect(areAllRequiredFieldsMapped(withOptionals)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for empty mappings array", () => {
|
||||
expect(areAllRequiredFieldsMapped([])).toBe(false);
|
||||
});
|
||||
|
||||
test("treats empty staticValue and missing sourceFieldId as unmapped", () => {
|
||||
test("treats whitespace-only staticValue as unmapped", () => {
|
||||
const incomplete: TFieldMapping[] = [
|
||||
{ targetFieldId: "collected_at", sourceFieldId: "ts" },
|
||||
{ targetFieldId: "source_type", sourceFieldId: "", staticValue: "" },
|
||||
{ targetFieldId: "field_id", sourceFieldId: "qid" },
|
||||
{ targetFieldId: "field_type", staticValue: "text" },
|
||||
...fullMappings.filter((m) => m.targetFieldId !== "field_type"),
|
||||
{ targetFieldId: "field_type", staticValue: " " },
|
||||
];
|
||||
expect(areAllRequiredFieldsMapped(incomplete)).toBe(false);
|
||||
expect(areAllRequiredCsvFieldsMapped(incomplete).missing).toContain("field_type");
|
||||
});
|
||||
|
||||
test("counts required field as mapped when only staticValue is set", () => {
|
||||
const onlyStatic: TFieldMapping[] = [
|
||||
{ targetFieldId: "collected_at", staticValue: "2026-01-01" },
|
||||
{ targetFieldId: "source_type", staticValue: "csv" },
|
||||
{ targetFieldId: "field_id", staticValue: "id" },
|
||||
{ targetFieldId: "field_type", staticValue: "text" },
|
||||
test("treats invalid static field_type as unmapped", () => {
|
||||
const invalidFieldType: TFieldMapping[] = [
|
||||
...fullMappings.filter((m) => m.targetFieldId !== "field_type"),
|
||||
{ targetFieldId: "field_type", staticValue: "not_a_field_type" },
|
||||
];
|
||||
expect(areAllRequiredFieldsMapped(onlyStatic)).toBe(true);
|
||||
expect(areAllRequiredCsvFieldsMapped(invalidFieldType).missing).toContain("field_type");
|
||||
});
|
||||
|
||||
test("does not require collected_at (defaults to $now)", () => {
|
||||
expect(areAllRequiredCsvFieldsMapped(fullMappings).missing).not.toContain("collected_at");
|
||||
});
|
||||
|
||||
test("does not require source_id", () => {
|
||||
expect(areAllRequiredCsvFieldsMapped(fullMappings).missing).not.toContain("source_id");
|
||||
});
|
||||
|
||||
test("does not require field_label", () => {
|
||||
const withoutFieldLabel = fullMappings.filter((m) => m.targetFieldId !== "field_label");
|
||||
|
||||
expect(areAllRequiredCsvFieldsMapped(withoutFieldLabel)).toEqual({ valid: true, missing: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSV_HIDDEN_STATIC_MAPPINGS", () => {
|
||||
test("persists source_type=csv with a valid static mapping shape", () => {
|
||||
expect(CSV_HIDDEN_STATIC_MAPPINGS).toEqual([
|
||||
{ sourceFieldId: "", targetFieldId: "source_type", staticValue: "csv" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("titleizeFromFileName", () => {
|
||||
test.each([
|
||||
["feedback.csv", "Feedback"],
|
||||
["q1-2026-survey.csv", "Q1 2026 Survey"],
|
||||
["customer_feedback_data.csv", "Customer Feedback Data"],
|
||||
["Mixed Case File.CSV", "Mixed Case File"],
|
||||
["nps results", "Nps Results"],
|
||||
["", ""],
|
||||
])("titleizes %s to %s", (input, expected) => {
|
||||
expect(titleizeFromFileName(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferFieldType", () => {
|
||||
test("detects integer numbers from samples", () => {
|
||||
expect(inferFieldType({ samples: ["3", "5", "10"] })).toBe("number");
|
||||
});
|
||||
|
||||
test("detects floating-point numbers from samples", () => {
|
||||
expect(inferFieldType({ samples: ["3.14", "-2.5"] })).toBe("number");
|
||||
});
|
||||
|
||||
test("detects booleans from samples", () => {
|
||||
expect(inferFieldType({ samples: ["true", "false", "yes", "no"] })).toBe("boolean");
|
||||
});
|
||||
|
||||
test("detects ISO dates from samples", () => {
|
||||
expect(inferFieldType({ samples: ["2026-01-01", "2026-02-15T10:00:00Z"] })).toBe("date");
|
||||
});
|
||||
|
||||
test("falls back to text for arbitrary strings", () => {
|
||||
expect(inferFieldType({ samples: ["hello", "world"] })).toBe("text");
|
||||
});
|
||||
|
||||
test("returns text for empty samples", () => {
|
||||
expect(inferFieldType({ samples: [] })).toBe("text");
|
||||
expect(inferFieldType({ samples: ["", " "] })).toBe("text");
|
||||
});
|
||||
|
||||
test("name hint wins over sample sniff (rating column with garbage samples)", () => {
|
||||
expect(inferFieldType({ columnName: "rating", samples: ["asdf", "qwer"] })).toBe("rating");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["nps", "nps"],
|
||||
["nps_score", "nps"],
|
||||
["csat", "csat"],
|
||||
["ces", "ces"],
|
||||
["stars", "rating"],
|
||||
["score", "rating"],
|
||||
["comment", "text"],
|
||||
["category", "categorical"],
|
||||
["is_promoter", "boolean"],
|
||||
["has_responded", "boolean"],
|
||||
["submitted_at", "date"],
|
||||
])("name hint %s → %s", (columnName, expected) => {
|
||||
expect(inferFieldType({ columnName, samples: [] })).toBe(expected);
|
||||
});
|
||||
|
||||
test("name with no hint falls back to sample sniffing", () => {
|
||||
expect(inferFieldType({ columnName: "anonymous", samples: ["42"] })).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoMapCsvSourceFields", () => {
|
||||
const buildSourceFields = (names: string[]): TSourceField[] =>
|
||||
names.map((name) => ({ id: name, name, type: "string", sampleValue: "" }));
|
||||
|
||||
test("maps timestamp column to collected_at with high confidence", () => {
|
||||
const result = autoMapCsvSourceFields({
|
||||
sourceFields: buildSourceFields(["timestamp", "answer"]),
|
||||
sampleRow: { timestamp: "2026-01-01", answer: "yes" },
|
||||
fileName: "feedback.csv",
|
||||
});
|
||||
const mapping = result.mappings.find((m) => m.targetFieldId === "collected_at");
|
||||
expect(mapping?.sourceFieldId).toBe("timestamp");
|
||||
expect(result.confidence.collected_at).toBe("high");
|
||||
});
|
||||
|
||||
test("falls back to $now when no timestamp column is present", () => {
|
||||
const result = autoMapCsvSourceFields({
|
||||
sourceFields: buildSourceFields(["question", "answer"]),
|
||||
sampleRow: { question: "q1", answer: "yes" },
|
||||
fileName: "feedback.csv",
|
||||
});
|
||||
const mapping = result.mappings.find((m) => m.targetFieldId === "collected_at");
|
||||
expect(mapping?.staticValue).toBe("$now");
|
||||
expect(result.confidence.collected_at).toBe("high");
|
||||
});
|
||||
|
||||
test("maps email to user_id with medium confidence", () => {
|
||||
const result = autoMapCsvSourceFields({
|
||||
sourceFields: buildSourceFields(["email", "answer"]),
|
||||
sampleRow: { email: "x@y.com", answer: "yes" },
|
||||
fileName: "feedback.csv",
|
||||
});
|
||||
const mapping = result.mappings.find((m) => m.targetFieldId === "user_id");
|
||||
expect(mapping?.sourceFieldId).toBe("email");
|
||||
expect(result.confidence.user_id).toBe("medium");
|
||||
});
|
||||
|
||||
test.each(["submission_id", "response_id", "record_id", "ticket_id", "order_id"])(
|
||||
"maps %s to submission_id with high confidence",
|
||||
(columnName) => {
|
||||
const result = autoMapCsvSourceFields({
|
||||
sourceFields: buildSourceFields([columnName, "question", "answer"]),
|
||||
sampleRow: { [columnName]: "stable-1", question: "q1", answer: "yes" },
|
||||
fileName: "feedback.csv",
|
||||
});
|
||||
const mapping = result.mappings.find((m) => m.targetFieldId === "submission_id");
|
||||
expect(mapping?.sourceFieldId).toBe(columnName);
|
||||
expect(result.confidence.submission_id).toBe("high");
|
||||
}
|
||||
);
|
||||
|
||||
test("prepopulates source_name from titleized filename", () => {
|
||||
const result = autoMapCsvSourceFields({
|
||||
sourceFields: buildSourceFields(["question", "answer"]),
|
||||
sampleRow: { question: "q1", answer: "yes" },
|
||||
fileName: "Q1-2026-survey.csv",
|
||||
});
|
||||
const mapping = result.mappings.find((m) => m.targetFieldId === "source_name");
|
||||
expect(mapping?.staticValue).toBe("Q1 2026 Survey");
|
||||
expect(result.confidence.source_name).toBe("high");
|
||||
});
|
||||
|
||||
test("keeps source_name filename-derived even when the CSV has a source_name column", () => {
|
||||
const result = autoMapCsvSourceFields({
|
||||
sourceFields: buildSourceFields(["source_name", "question", "answer"]),
|
||||
sampleRow: { source_name: "malicious", question: "q1", answer: "yes" },
|
||||
fileName: "trusted-file.csv",
|
||||
});
|
||||
const mapping = result.mappings.find((m) => m.targetFieldId === "source_name");
|
||||
expect(mapping).toEqual({ targetFieldId: "source_name", staticValue: "Trusted File" });
|
||||
});
|
||||
|
||||
test("ambiguous column claimed by highest-confidence target", () => {
|
||||
const result = autoMapCsvSourceFields({
|
||||
sourceFields: buildSourceFields(["question_id", "id"]),
|
||||
sampleRow: { question_id: "q1", id: "u1" },
|
||||
fileName: "x.csv",
|
||||
});
|
||||
const fieldIdMapping = result.mappings.find((m) => m.targetFieldId === "field_id");
|
||||
expect(fieldIdMapping?.sourceFieldId).toBe("question_id");
|
||||
expect(result.confidence.field_id).toBe("high");
|
||||
});
|
||||
|
||||
test("maps realistic QA headers without leaving required basics unresolved", () => {
|
||||
const result = autoMapCsvSourceFields({
|
||||
sourceFields: buildSourceFields([
|
||||
"timestamp",
|
||||
"response_id",
|
||||
"email",
|
||||
"question",
|
||||
"answer",
|
||||
"language",
|
||||
"score",
|
||||
]),
|
||||
sampleRow: {
|
||||
timestamp: "2026-01-01",
|
||||
response_id: "resp-1",
|
||||
email: "person@example.com",
|
||||
question: "How satisfied are you?",
|
||||
answer: "Great",
|
||||
language: "en",
|
||||
score: "9",
|
||||
},
|
||||
fileName: "google-forms-export.csv",
|
||||
});
|
||||
|
||||
const validation = areAllRequiredCsvFieldsMapped(result.mappings);
|
||||
expect(validation).toEqual({ valid: true, missing: [] });
|
||||
expect(result.mappings.find((m) => m.targetFieldId === "field_id")?.sourceFieldId).toBe("question");
|
||||
expect(result.mappings.find((m) => m.targetFieldId === "field_label")?.sourceFieldId).toBe("question");
|
||||
expect(result.confidence.field_id).toBe("low");
|
||||
});
|
||||
|
||||
test("infers field_type as static via sample sniffing when name has no hint", () => {
|
||||
const result = autoMapCsvSourceFields({
|
||||
sourceFields: buildSourceFields(["question", "value"]),
|
||||
sampleRow: { question: "q1", value: "42" },
|
||||
fileName: "x.csv",
|
||||
});
|
||||
const fieldTypeMapping = result.mappings.find((m) => m.targetFieldId === "field_type");
|
||||
expect(fieldTypeMapping?.staticValue).toBe("number");
|
||||
expect(result.confidence.field_type).toBe("medium");
|
||||
});
|
||||
|
||||
test("infers field_type as 'rating' (high confidence) when response_value column is named 'rating'", () => {
|
||||
const result = autoMapCsvSourceFields({
|
||||
sourceFields: buildSourceFields(["question", "rating"]),
|
||||
sampleRow: { question: "q1", rating: "garbage" },
|
||||
fileName: "x.csv",
|
||||
});
|
||||
const mapping = result.mappings.find((m) => m.targetFieldId === "field_type");
|
||||
expect(mapping?.staticValue).toBe("rating");
|
||||
expect(result.confidence.field_type).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { TConnectorType, THubFieldType } from "@formbricks/types/connector";
|
||||
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
|
||||
import { TConnectorType, THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
|
||||
import {
|
||||
CSV_REQUIRED_UI_FIELDS,
|
||||
CSV_TARGET_FIELDS,
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
MAX_CSV_VALUES,
|
||||
TFieldMapping,
|
||||
TSourceField,
|
||||
} from "./types";
|
||||
|
||||
export type TConnectorOptionId = TConnectorType | "api_ingestion" | "feedback_record_mcp";
|
||||
|
||||
@@ -39,10 +46,18 @@ export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
|
||||
},
|
||||
];
|
||||
|
||||
export const parseCSVColumnsToFields = (columns: string): TSourceField[] => {
|
||||
export const parseCSVColumnsToFields = (
|
||||
columns: string,
|
||||
{ includeSampleValues = true }: { includeSampleValues?: boolean } = {}
|
||||
): TSourceField[] => {
|
||||
return columns.split(",").map((col) => {
|
||||
const trimmed = col.trim();
|
||||
return { id: trimmed, name: trimmed, type: "string", sampleValue: `Sample ${trimmed}` };
|
||||
return {
|
||||
id: trimmed,
|
||||
name: trimmed,
|
||||
type: "string",
|
||||
sampleValue: includeSampleValues ? `Sample ${trimmed}` : undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -52,10 +67,6 @@ export interface TEnumValidationError {
|
||||
allowedValues: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that CSV columns mapped to enum target fields contain only allowed values.
|
||||
* Returns an array of validation errors (empty if all valid).
|
||||
*/
|
||||
export const validateEnumMappings = (
|
||||
mappings: TFieldMapping[],
|
||||
csvData: Record<string, string>[]
|
||||
@@ -92,24 +103,6 @@ export const validateEnumMappings = (
|
||||
|
||||
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
|
||||
|
||||
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
|
||||
const requiredFieldIds = new Set(
|
||||
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
|
||||
);
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!requiredFieldIds.has(mapping.targetFieldId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mapping.sourceFieldId || mapping.staticValue) {
|
||||
requiredFieldIds.delete(mapping.targetFieldId);
|
||||
}
|
||||
}
|
||||
|
||||
return requiredFieldIds.size === 0;
|
||||
};
|
||||
|
||||
export const toggleQuestionId = (currentSelection: string[], questionId: string): string[] => {
|
||||
return currentSelection.includes(questionId)
|
||||
? currentSelection.filter((id) => id !== questionId)
|
||||
@@ -131,3 +124,235 @@ export const validateCsvFile = (
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export type TMappingConfidence = "high" | "medium" | "low";
|
||||
|
||||
export const titleizeFromFileName = (fileName: string): string => {
|
||||
const base = fileName.replace(/\.csv$/i, "");
|
||||
const words = base.split(/[_\-\s]+/).filter(Boolean);
|
||||
if (words.length === 0) return base;
|
||||
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" ");
|
||||
};
|
||||
|
||||
export const CSV_COLUMN_ALIASES: Record<string, { high: RegExp[]; medium: RegExp[] }> = {
|
||||
collected_at: {
|
||||
high: [/^(timestamp|collected_at|submitted_at)$/i],
|
||||
medium: [/^(created_at|date|time|datetime)$/i],
|
||||
},
|
||||
field_id: {
|
||||
high: [/^(field_id|question_id|q_id)$/i],
|
||||
medium: [/^(id|key)$/i],
|
||||
},
|
||||
field_label: {
|
||||
high: [/^(field_label|question|label|question_text)$/i],
|
||||
medium: [/^(name|title|prompt)$/i],
|
||||
},
|
||||
response_value: {
|
||||
high: [/^(response|answer|value|response_value)$/i],
|
||||
medium: [/^(score|rating|feedback)$/i],
|
||||
},
|
||||
source_id: {
|
||||
high: [/^(source_id|survey_id|form_id)$/i],
|
||||
medium: [],
|
||||
},
|
||||
submission_id: {
|
||||
high: [
|
||||
/^(submission_id|submissionid|response_id|responseid|record_id|recordid|ticket_id|ticketid|order_id|orderid|request_id|requestid|case_id|caseid)$/i,
|
||||
],
|
||||
medium: [/^(submission|record|ticket|order|request|case)$/i],
|
||||
},
|
||||
language: {
|
||||
high: [/^(language|lang|locale)$/i],
|
||||
medium: [],
|
||||
},
|
||||
user_id: {
|
||||
high: [/^(user_id|user_identifier|customer_id)$/i],
|
||||
medium: [/^(email|user|customer)$/i],
|
||||
},
|
||||
metadata: {
|
||||
high: [/^metadata$/i],
|
||||
medium: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const FIELD_TYPE_NAME_HINTS: Array<{ pattern: RegExp; type: THubFieldType }> = [
|
||||
{ pattern: /^(rating|stars|score)$/i, type: "rating" },
|
||||
{ pattern: /^(nps|nps_score|net_promoter)$/i, type: "nps" },
|
||||
{ pattern: /^csat$/i, type: "csat" },
|
||||
{ pattern: /^ces$/i, type: "ces" },
|
||||
{ pattern: /^(number|count|amount|qty|quantity)$/i, type: "number" },
|
||||
{ pattern: /^(comment|feedback|answer|response|text)$/i, type: "text" },
|
||||
{ pattern: /^(category|choice|option|select)$/i, type: "categorical" },
|
||||
{ pattern: /^(is_|has_|did_)/i, type: "boolean" },
|
||||
{ pattern: /^(date|submitted_at|completed_at)$/i, type: "date" },
|
||||
];
|
||||
|
||||
export const inferFieldType = ({
|
||||
columnName,
|
||||
samples,
|
||||
}: {
|
||||
columnName?: string;
|
||||
samples: string[];
|
||||
}): THubFieldType => {
|
||||
if (columnName) {
|
||||
for (const hint of FIELD_TYPE_NAME_HINTS) {
|
||||
if (hint.pattern.test(columnName)) return hint.type;
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned = samples.map((s) => s?.trim()).filter((s): s is string => Boolean(s));
|
||||
if (cleaned.length === 0) return "text";
|
||||
|
||||
const isBool = cleaned.every((s) => /^(true|false|yes|no|0|1)$/i.test(s));
|
||||
if (isBool) return "boolean";
|
||||
|
||||
const isNumber = cleaned.every((s) => !Number.isNaN(Number.parseFloat(s)) && /^-?\d+(\.\d+)?$/.test(s));
|
||||
if (isNumber) return "number";
|
||||
|
||||
const isDate = cleaned.every((s) => !Number.isNaN(new Date(s).getTime()));
|
||||
if (isDate) return "date";
|
||||
|
||||
return "text";
|
||||
};
|
||||
|
||||
interface TAutoMapResult {
|
||||
mappings: TFieldMapping[];
|
||||
confidence: Record<string, TMappingConfidence>;
|
||||
}
|
||||
|
||||
interface TAutoMapInput {
|
||||
sourceFields: TSourceField[];
|
||||
sampleRow: Record<string, string>;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
const findBestSourceMatch = (
|
||||
targetId: string,
|
||||
sourceFields: TSourceField[]
|
||||
): { sourceField: TSourceField; confidence: TMappingConfidence } | null => {
|
||||
const aliases = CSV_COLUMN_ALIASES[targetId];
|
||||
if (!aliases) return null;
|
||||
|
||||
for (const pattern of aliases.high) {
|
||||
const match = sourceFields.find((f) => pattern.test(f.name));
|
||||
if (match) return { sourceField: match, confidence: "high" };
|
||||
}
|
||||
for (const pattern of aliases.medium) {
|
||||
const match = sourceFields.find((f) => pattern.test(f.name));
|
||||
if (match) return { sourceField: match, confidence: "medium" };
|
||||
}
|
||||
const idToken = targetId.split("_").pop() ?? targetId;
|
||||
const fuzzy = sourceFields.find((f) => f.name.toLowerCase().includes(idToken.toLowerCase()));
|
||||
if (fuzzy) return { sourceField: fuzzy, confidence: "low" };
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const autoMapCsvSourceFields = ({
|
||||
sourceFields,
|
||||
sampleRow,
|
||||
fileName,
|
||||
}: TAutoMapInput): TAutoMapResult => {
|
||||
const mappings: TFieldMapping[] = [];
|
||||
const confidence: Record<string, TMappingConfidence> = {};
|
||||
const claimedSources = new Set<string>();
|
||||
|
||||
const orderedTargets = CSV_TARGET_FIELDS.map((t) => t.id);
|
||||
|
||||
for (const targetId of orderedTargets) {
|
||||
const aliases = CSV_COLUMN_ALIASES[targetId];
|
||||
if (!aliases) continue;
|
||||
for (const pattern of aliases.high) {
|
||||
const match = sourceFields.find((f) => !claimedSources.has(f.id) && pattern.test(f.name));
|
||||
if (match) {
|
||||
mappings.push({ targetFieldId: targetId, sourceFieldId: match.id });
|
||||
confidence[targetId] = "high";
|
||||
claimedSources.add(match.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const targetId of orderedTargets) {
|
||||
if (confidence[targetId]) continue;
|
||||
const aliases = CSV_COLUMN_ALIASES[targetId];
|
||||
if (!aliases) continue;
|
||||
for (const pattern of aliases.medium) {
|
||||
const match = sourceFields.find((f) => !claimedSources.has(f.id) && pattern.test(f.name));
|
||||
if (match) {
|
||||
mappings.push({ targetFieldId: targetId, sourceFieldId: match.id });
|
||||
confidence[targetId] = "medium";
|
||||
claimedSources.add(match.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const targetId of orderedTargets) {
|
||||
if (confidence[targetId]) continue;
|
||||
const remaining = sourceFields.filter((f) => !claimedSources.has(f.id));
|
||||
const guess = findBestSourceMatch(targetId, remaining);
|
||||
if (guess && guess.confidence === "low") {
|
||||
mappings.push({ targetFieldId: targetId, sourceFieldId: guess.sourceField.id });
|
||||
confidence[targetId] = "low";
|
||||
claimedSources.add(guess.sourceField.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!confidence.collected_at) {
|
||||
mappings.push({ targetFieldId: "collected_at", staticValue: "$now" });
|
||||
confidence.collected_at = "high";
|
||||
}
|
||||
|
||||
mappings.push({ targetFieldId: "source_name", staticValue: titleizeFromFileName(fileName) });
|
||||
confidence.source_name = "high";
|
||||
|
||||
if (!confidence.field_id) {
|
||||
const labelMapping = mappings.find((m) => m.targetFieldId === "field_label" && m.sourceFieldId);
|
||||
if (labelMapping?.sourceFieldId) {
|
||||
mappings.push({ targetFieldId: "field_id", sourceFieldId: labelMapping.sourceFieldId });
|
||||
confidence.field_id = "low";
|
||||
}
|
||||
}
|
||||
|
||||
if (!confidence.field_type) {
|
||||
const responseMapping = mappings.find((m) => m.targetFieldId === "response_value");
|
||||
if (responseMapping?.sourceFieldId) {
|
||||
const sourceField = sourceFields.find((f) => f.id === responseMapping.sourceFieldId);
|
||||
const inferred = inferFieldType({
|
||||
columnName: sourceField?.name,
|
||||
samples: [sampleRow[responseMapping.sourceFieldId] ?? ""],
|
||||
});
|
||||
mappings.push({ targetFieldId: "field_type", staticValue: inferred });
|
||||
const nameHinted = sourceField?.name
|
||||
? FIELD_TYPE_NAME_HINTS.some((h) => h.pattern.test(sourceField.name))
|
||||
: false;
|
||||
confidence.field_type = nameHinted ? "high" : "medium";
|
||||
}
|
||||
}
|
||||
|
||||
return { mappings, confidence };
|
||||
};
|
||||
|
||||
export const areAllRequiredCsvFieldsMapped = (
|
||||
mappings: TFieldMapping[]
|
||||
): { valid: boolean; missing: string[] } => {
|
||||
const missing: string[] = [];
|
||||
for (const requiredId of CSV_REQUIRED_UI_FIELDS) {
|
||||
const mapping = mappings.find((m) => m.targetFieldId === requiredId);
|
||||
const resolved = Boolean(mapping?.sourceFieldId || mapping?.staticValue?.trim());
|
||||
if (!resolved) {
|
||||
missing.push(requiredId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
requiredId === "field_type" &&
|
||||
mapping?.staticValue &&
|
||||
!ZHubFieldType.safeParse(mapping.staticValue).success
|
||||
) {
|
||||
missing.push(requiredId);
|
||||
}
|
||||
}
|
||||
return { valid: missing.length === 0, missing };
|
||||
};
|
||||
|
||||
@@ -78,3 +78,10 @@ export const ZUpdateFeedbackRecordAction = z.object({
|
||||
});
|
||||
|
||||
export type TUpdateFeedbackRecordAction = z.infer<typeof ZUpdateFeedbackRecordAction>;
|
||||
|
||||
export const ZDeleteFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordId: ZFeedbackRecordId,
|
||||
});
|
||||
|
||||
export type TDeleteFeedbackRecordAction = z.infer<typeof ZDeleteFeedbackRecordAction>;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
|
||||
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
|
||||
import { buildEnvoyAllowResponse, parseEnvoyRequestMetadata } from "./shared";
|
||||
import { feedbackRecordsEnvoyAuthorizer } from "@/modules/hub/feedback-records-gateway";
|
||||
import {
|
||||
TEnvoyRequestAuthorizer,
|
||||
authenticateEnvoyRequest,
|
||||
buildStatusResponse,
|
||||
parseEnvoyRequestMetadata,
|
||||
} from "./shared";
|
||||
|
||||
const envoyAuthorizers: TEnvoyRequestAuthorizer[] = [feedbackRecordsEnvoyAuthorizer];
|
||||
|
||||
export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Response> => {
|
||||
const requestMetadata = parseEnvoyRequestMetadata(request);
|
||||
@@ -10,12 +16,20 @@ export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Respo
|
||||
return requestMetadata.errorResponse;
|
||||
}
|
||||
|
||||
return await authorizeGatewayRequest({
|
||||
const authorizer = envoyAuthorizers.find((candidate) => candidate.matches(requestMetadata.originalRequest));
|
||||
if (!authorizer) {
|
||||
return buildStatusResponse(400, "Unsupported Envoy auth route");
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateEnvoyRequest(request, authorizer.gatewayToken);
|
||||
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
|
||||
return buildStatusResponse(401, "Unauthorized");
|
||||
}
|
||||
|
||||
return await authorizer.authorize({
|
||||
request,
|
||||
originalRequest: requestMetadata.originalRequest,
|
||||
authorizers: gatewayRequestAuthorizers,
|
||||
principal: authenticationResult.principal,
|
||||
requestId: request.headers.get("x-request-id") ?? "unknown",
|
||||
buildAllowResponse: buildEnvoyAllowResponse,
|
||||
unsupportedRouteMessage: "Unsupported Envoy auth route",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getProxySession } from "@/modules/auth/lib/proxy-session";
|
||||
|
||||
const ENVOY_AUTH_PREFIX = "/api/envoy-auth";
|
||||
const HEADERS_TO_REMOVE_ON_ALLOW = "x-api-key,authorization,cookie";
|
||||
|
||||
export const buildEnvoyAllowResponse = (): Response =>
|
||||
export type TEnvoyOriginalRequest = {
|
||||
method: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type TEnvoyAuthenticatedPrincipal =
|
||||
| {
|
||||
type: "apiKey";
|
||||
authentication: TAuthenticationApiKey;
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
userId: string;
|
||||
source: "session" | "jwt";
|
||||
};
|
||||
|
||||
export type TEnvoyGatewayTokenHandler = {
|
||||
getTokenFromHeaders: (headers: Headers) => string | null;
|
||||
verifyToken: (token: string) => { userId: string };
|
||||
};
|
||||
|
||||
export type TEnvoyAuthenticationResult =
|
||||
| { status: "authenticated"; principal: TEnvoyAuthenticatedPrincipal }
|
||||
| { status: "invalid" }
|
||||
| { status: "missing" };
|
||||
|
||||
export type TEnvoyRequestAuthorizer = {
|
||||
matches: (originalRequest: TEnvoyOriginalRequest) => boolean;
|
||||
gatewayToken?: TEnvoyGatewayTokenHandler;
|
||||
authorize: (params: {
|
||||
request: NextRequest;
|
||||
originalRequest: TEnvoyOriginalRequest;
|
||||
principal: TEnvoyAuthenticatedPrincipal;
|
||||
requestId: string;
|
||||
}) => Promise<Response>;
|
||||
};
|
||||
|
||||
export const buildAllowResponse = (): Response =>
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -13,12 +53,20 @@ export const buildEnvoyAllowResponse = (): Response =>
|
||||
},
|
||||
});
|
||||
|
||||
export const buildStatusResponse = (status: number, message: string): Response =>
|
||||
new Response(message, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
export const parseEnvoyRequestMetadata = (
|
||||
request: NextRequest
|
||||
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
|
||||
): { originalRequest: TEnvoyOriginalRequest } | { errorResponse: Response } => {
|
||||
if (!request.nextUrl.pathname.startsWith(`${ENVOY_AUTH_PREFIX}/`)) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid Envoy auth request path"),
|
||||
errorResponse: buildStatusResponse(400, "Invalid Envoy auth request path"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +77,7 @@ export const parseEnvoyRequestMetadata = (
|
||||
|
||||
if (originalPathSegments.length === 0) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Missing original request path"),
|
||||
errorResponse: buildStatusResponse(400, "Missing original request path"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,7 +93,69 @@ export const parseEnvoyRequestMetadata = (
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid original request path"),
|
||||
errorResponse: buildStatusResponse(400, "Invalid original request path"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const authenticateEnvoyRequest = async (
|
||||
request: NextRequest,
|
||||
gatewayToken?: TEnvoyGatewayTokenHandler
|
||||
): Promise<TEnvoyAuthenticationResult> => {
|
||||
if (getApiKeyFromHeaders(request.headers)) {
|
||||
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
|
||||
if (!apiKeyAuthentication) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "apiKey",
|
||||
authentication: apiKeyAuthentication,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (gatewayToken) {
|
||||
const token = gatewayToken.getTokenFromHeaders(request.headers);
|
||||
if (token) {
|
||||
try {
|
||||
const { userId } = gatewayToken.verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || user.isActive === false) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: user.id,
|
||||
source: "jwt",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proxySession = await getProxySession(request);
|
||||
if (!proxySession) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: proxySession.userId,
|
||||
source: "session",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import "server-only";
|
||||
import { feedbackRecordsGatewayAuthorizer } from "@/modules/hub/feedback-records-gateway";
|
||||
import { TGatewayRequestAuthorizer } from "./request";
|
||||
|
||||
export const gatewayRequestAuthorizers: TGatewayRequestAuthorizer[] = [feedbackRecordsGatewayAuthorizer];
|
||||
@@ -1,152 +0,0 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getProxySession } from "@/modules/auth/lib/proxy-session";
|
||||
|
||||
export type TGatewayOriginalRequest = {
|
||||
method: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type TGatewayAuthenticatedPrincipal =
|
||||
| {
|
||||
type: "apiKey";
|
||||
authentication: TAuthenticationApiKey;
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
userId: string;
|
||||
source: "session" | "jwt";
|
||||
};
|
||||
|
||||
export type TGatewayTokenHandler = {
|
||||
getTokenFromHeaders: (headers: Headers) => string | null;
|
||||
verifyToken: (token: string) => { userId: string };
|
||||
};
|
||||
|
||||
export type TGatewayAuthenticationResult =
|
||||
| { status: "authenticated"; principal: TGatewayAuthenticatedPrincipal }
|
||||
| { status: "invalid" }
|
||||
| { status: "missing" };
|
||||
|
||||
export type TGatewayAuthorizationDecision = { status: "allow" } | { status: "deny"; response: Response };
|
||||
|
||||
export type TGatewayRequestAuthorizer = {
|
||||
matches: (originalRequest: TGatewayOriginalRequest) => boolean;
|
||||
gatewayToken?: TGatewayTokenHandler;
|
||||
authorize: (params: {
|
||||
request: NextRequest;
|
||||
originalRequest: TGatewayOriginalRequest;
|
||||
principal: TGatewayAuthenticatedPrincipal;
|
||||
requestId: string;
|
||||
}) => Promise<TGatewayAuthorizationDecision>;
|
||||
};
|
||||
|
||||
export const buildGatewayStatusResponse = (status: number, message: string): Response =>
|
||||
new Response(message, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
export const allowGatewayRequest = (): TGatewayAuthorizationDecision => ({ status: "allow" });
|
||||
|
||||
export const authenticateGatewayRequest = async (
|
||||
request: NextRequest,
|
||||
gatewayToken?: TGatewayTokenHandler
|
||||
): Promise<TGatewayAuthenticationResult> => {
|
||||
if (getApiKeyFromHeaders(request.headers)) {
|
||||
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
|
||||
if (!apiKeyAuthentication) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "apiKey",
|
||||
authentication: apiKeyAuthentication,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (gatewayToken) {
|
||||
const token = gatewayToken.getTokenFromHeaders(request.headers);
|
||||
if (token) {
|
||||
try {
|
||||
const { userId } = gatewayToken.verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || user.isActive === false) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: user.id,
|
||||
source: "jwt",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proxySession = await getProxySession(request);
|
||||
if (!proxySession) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: proxySession.userId,
|
||||
source: "session",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const authorizeGatewayRequest = async ({
|
||||
request,
|
||||
originalRequest,
|
||||
authorizers,
|
||||
requestId,
|
||||
buildAllowResponse,
|
||||
unsupportedRouteMessage,
|
||||
}: {
|
||||
request: NextRequest;
|
||||
originalRequest: TGatewayOriginalRequest;
|
||||
authorizers: TGatewayRequestAuthorizer[];
|
||||
requestId: string;
|
||||
buildAllowResponse: () => Response;
|
||||
unsupportedRouteMessage: string;
|
||||
}): Promise<Response> => {
|
||||
const authorizer = authorizers.find((candidate) => candidate.matches(originalRequest));
|
||||
if (!authorizer) {
|
||||
return buildGatewayStatusResponse(400, unsupportedRouteMessage);
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateGatewayRequest(request, authorizer.gatewayToken);
|
||||
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
|
||||
return buildGatewayStatusResponse(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const authorizationDecision = await authorizer.authorize({
|
||||
request,
|
||||
originalRequest,
|
||||
principal: authenticationResult.principal,
|
||||
requestId,
|
||||
});
|
||||
|
||||
return authorizationDecision.status === "allow" ? buildAllowResponse() : authorizationDecision.response;
|
||||
};
|
||||
@@ -12,11 +12,11 @@ import { getBearerTokenFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getFeedbackDirectoryAuthContext } from "@/modules/ee/feedback-directory/lib/feedback-directory";
|
||||
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
TGatewayAuthenticatedPrincipal,
|
||||
TGatewayRequestAuthorizer,
|
||||
allowGatewayRequest,
|
||||
buildGatewayStatusResponse,
|
||||
} from "@/modules/gateway-auth/lib/request";
|
||||
TEnvoyAuthenticatedPrincipal,
|
||||
TEnvoyRequestAuthorizer,
|
||||
buildAllowResponse,
|
||||
buildStatusResponse,
|
||||
} from "@/modules/envoy-auth/shared";
|
||||
import { getFeedbackRecordTenant } from "@/modules/hub/service";
|
||||
|
||||
const FEEDBACK_RECORDS_V3_PREFIX = "/api/v3/feedbackRecords";
|
||||
@@ -137,7 +137,7 @@ const parseFeedbackRecordsGatewayRoute = (method: string, pathname: string): TPa
|
||||
return null;
|
||||
};
|
||||
|
||||
type TAuthenticatedGatewayPrincipal = TGatewayAuthenticatedPrincipal;
|
||||
type TAuthenticatedGatewayPrincipal = TEnvoyAuthenticatedPrincipal;
|
||||
|
||||
const parseTenantId = (tenantId: string | null): string | null => {
|
||||
if (!tenantId) {
|
||||
@@ -200,7 +200,7 @@ const resolveTenantId = async (
|
||||
const tenantId = parseTenantId(originalUrl.searchParams.get("tenant_id"));
|
||||
if (!tenantId) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ const resolveTenantId = async (
|
||||
const tenantId = parseTenantId(typeof body?.tenant_id === "string" ? body.tenant_id : null);
|
||||
if (!tenantId) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup returned not found"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(403, "Forbidden"),
|
||||
errorResponse: buildStatusResponse(403, "Forbidden"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup failed"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
|
||||
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,14 +247,14 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup returned invalid tenant"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
|
||||
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
|
||||
};
|
||||
}
|
||||
|
||||
return { tenantId };
|
||||
};
|
||||
|
||||
const authorizeFeedbackRecordsGatewayRequest = async (
|
||||
const authorizeGatewayRequest = async (
|
||||
principal: TAuthenticatedGatewayPrincipal,
|
||||
feedbackDirectoryId: string,
|
||||
requiredPermission: TFeedbackRecordsGatewayPermission
|
||||
@@ -308,7 +308,7 @@ const authorizeFeedbackRecordsGatewayRequest = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
|
||||
export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
matches: (originalRequest) => normalizeFeedbackRecordsPath(originalRequest.url.pathname) !== null,
|
||||
gatewayToken: {
|
||||
getTokenFromHeaders: getFeedbackRecordsGatewayJwtFromHeaders,
|
||||
@@ -317,21 +317,15 @@ export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
|
||||
authorize: async ({ request, originalRequest, principal, requestId }) => {
|
||||
const route = parseFeedbackRecordsGatewayRoute(originalRequest.method, originalRequest.url.pathname);
|
||||
if (!route) {
|
||||
return {
|
||||
status: "deny",
|
||||
response: buildGatewayStatusResponse(400, "Unsupported FeedbackRecords route"),
|
||||
};
|
||||
return buildStatusResponse(400, "Unsupported FeedbackRecords route");
|
||||
}
|
||||
|
||||
const tenantResolution = await resolveTenantId(request, route, originalRequest.url, requestId);
|
||||
if ("errorResponse" in tenantResolution) {
|
||||
return {
|
||||
status: "deny",
|
||||
response: tenantResolution.errorResponse,
|
||||
};
|
||||
return tenantResolution.errorResponse;
|
||||
}
|
||||
|
||||
const authorizationResult = await authorizeFeedbackRecordsGatewayRequest(
|
||||
const authorizationResult = await authorizeGatewayRequest(
|
||||
principal,
|
||||
tenantResolution.tenantId,
|
||||
route.requiredPermission
|
||||
@@ -347,10 +341,7 @@ export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
|
||||
},
|
||||
"Feedback records gateway authorization denied"
|
||||
);
|
||||
return {
|
||||
status: "deny",
|
||||
response: buildGatewayStatusResponse(403, "Forbidden"),
|
||||
};
|
||||
return buildStatusResponse(403, "Forbidden");
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -364,6 +355,6 @@ export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
|
||||
"Feedback records gateway authorization allowed"
|
||||
);
|
||||
|
||||
return allowGatewayRequest();
|
||||
return buildAllowResponse();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import FormbricksHub from "@formbricks/hub";
|
||||
import {
|
||||
createFeedbackRecord,
|
||||
createFeedbackRecordsBatch,
|
||||
deleteFeedbackRecord,
|
||||
getFeedbackRecordTenant,
|
||||
listFeedbackRecords,
|
||||
retrieveFeedbackRecord,
|
||||
@@ -278,6 +279,53 @@ describe("hub service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteFeedbackRecord", () => {
|
||||
test("returns config error when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
const result = await deleteFeedbackRecord("rec-1");
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error?.message).toContain("HUB_API_KEY");
|
||||
});
|
||||
|
||||
test("returns data when client.delete resolves", async () => {
|
||||
const deleteSpy = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { delete: deleteSpy },
|
||||
} as any);
|
||||
|
||||
const result = await deleteFeedbackRecord("rec-1");
|
||||
|
||||
expect(deleteSpy).toHaveBeenCalledWith("rec-1");
|
||||
expect(result.data).toEqual({ deleted: true });
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
test("returns error when client.delete throws APIError", async () => {
|
||||
const apiError = new (FormbricksHub as any).APIError("Forbidden", 403);
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { delete: vi.fn().mockRejectedValue(apiError) },
|
||||
} as any);
|
||||
|
||||
const result = await deleteFeedbackRecord("rec-1");
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({ status: 403, message: "Forbidden" });
|
||||
});
|
||||
|
||||
test("returns error when client.delete throws non-API error", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { delete: vi.fn().mockRejectedValue(new Error("network")) },
|
||||
} as any);
|
||||
|
||||
const result = await deleteFeedbackRecord("rec-1");
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({ status: 0, message: "network" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeedbackRecordsBatch", () => {
|
||||
test("returns all errors when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
@@ -98,6 +98,31 @@ export const updateFeedbackRecord = async (
|
||||
}
|
||||
};
|
||||
|
||||
export type HubFeedbackRecordDeleteResult = {
|
||||
data: { deleted: true } | null;
|
||||
error: HubError | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a single feedback record in the Hub by id.
|
||||
*/
|
||||
export const deleteFeedbackRecord = async (id: string): Promise<HubFeedbackRecordDeleteResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
|
||||
try {
|
||||
await client.feedbackRecords.delete(id);
|
||||
return { data: { deleted: true }, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, id }, "Hub: deleteFeedbackRecord failed");
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const message = getErrorMessage(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
}
|
||||
};
|
||||
|
||||
export type ListFeedbackRecordsResult = {
|
||||
data: FeedbackRecordListResponse | null;
|
||||
error: HubError | null;
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { authorizeTraefikRequest } from "./service";
|
||||
|
||||
const {
|
||||
mockAuthenticateApiKeyFromHeaders,
|
||||
mockGetApiKeyFromHeaders,
|
||||
mockGetBearerTokenFromHeaders,
|
||||
mockGetProxySession,
|
||||
mockVerifyFeedbackRecordsGatewayToken,
|
||||
mockGetFeedbackDirectoryAuthContext,
|
||||
mockGetFeedbackRecordTenant,
|
||||
mockCheckAuthorizationUpdated,
|
||||
mockUserFindUnique,
|
||||
mockGetIsUnifyFeedbackEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthenticateApiKeyFromHeaders: vi.fn(),
|
||||
mockGetApiKeyFromHeaders: vi.fn(),
|
||||
mockGetBearerTokenFromHeaders: vi.fn(),
|
||||
mockGetProxySession: vi.fn(),
|
||||
mockVerifyFeedbackRecordsGatewayToken: vi.fn(),
|
||||
mockGetFeedbackDirectoryAuthContext: vi.fn(),
|
||||
mockGetFeedbackRecordTenant: vi.fn(),
|
||||
mockCheckAuthorizationUpdated: vi.fn(),
|
||||
mockUserFindUnique: vi.fn(),
|
||||
mockGetIsUnifyFeedbackEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/api-key-auth", () => ({
|
||||
authenticateApiKeyFromHeaders: mockAuthenticateApiKeyFromHeaders,
|
||||
getApiKeyFromHeaders: mockGetApiKeyFromHeaders,
|
||||
getBearerTokenFromHeaders: mockGetBearerTokenFromHeaders,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/proxy-session", () => ({
|
||||
getProxySession: mockGetProxySession,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jwt", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/jwt")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyFeedbackRecordsGatewayToken: mockVerifyFeedbackRecordsGatewayToken,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mockUserFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({
|
||||
getFeedbackDirectoryAuthContext: mockGetFeedbackDirectoryAuthContext,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsUnifyFeedbackEnabled: mockGetIsUnifyFeedbackEnabled,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/hub/service", () => ({
|
||||
getFeedbackRecordTenant: mockGetFeedbackRecordTenant,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: mockCheckAuthorizationUpdated,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const feedbackDirectoryId = "clxx1234567890123456789012";
|
||||
const feedbackRecordId = "0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8";
|
||||
|
||||
const createRequest = ({
|
||||
method = "GET",
|
||||
forwardedMethod = "GET",
|
||||
forwardedUri,
|
||||
headers = {},
|
||||
body,
|
||||
adapterUrl = "http://localhost/api/traefik-auth/feedback-records",
|
||||
}: {
|
||||
method?: string;
|
||||
forwardedMethod?: string;
|
||||
forwardedUri?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyInit;
|
||||
adapterUrl?: string;
|
||||
} = {}) =>
|
||||
new NextRequest(adapterUrl, {
|
||||
method,
|
||||
headers: {
|
||||
"x-forwarded-method": forwardedMethod,
|
||||
...(forwardedUri ? { "x-forwarded-uri": forwardedUri } : {}),
|
||||
"x-forwarded-host": "app.example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
...headers,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
describe("authorizeTraefikRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetApiKeyFromHeaders.mockReturnValue(null);
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue(null);
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue(null);
|
||||
mockGetProxySession.mockResolvedValue(null);
|
||||
mockVerifyFeedbackRecordsGatewayToken.mockImplementation(() => {
|
||||
throw new Error("invalid token");
|
||||
});
|
||||
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
|
||||
organizationId: "org_1",
|
||||
workspaceIds: ["workspace_1"],
|
||||
isArchived: false,
|
||||
});
|
||||
mockGetFeedbackRecordTenant.mockResolvedValue({
|
||||
data: { tenantId: feedbackDirectoryId },
|
||||
error: null,
|
||||
});
|
||||
mockCheckAuthorizationUpdated.mockResolvedValue(true);
|
||||
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true });
|
||||
mockGetIsUnifyFeedbackEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("allows requests using Traefik forwarded method and URI metadata", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
method: "POST",
|
||||
forwardedMethod: "POST",
|
||||
forwardedUri: "/api/v3/feedbackRecords",
|
||||
headers: {
|
||||
authorization: "Bearer fbk_test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("x-envoy-auth-headers-to-remove")).toBeNull();
|
||||
});
|
||||
|
||||
test("uses the forwarded URI instead of the Traefik auth endpoint URL", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
adapterUrl: "http://localhost/api/traefik-auth/not-the-original-route",
|
||||
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("returns 400 when Traefik forwarded metadata is missing", async () => {
|
||||
const response = await authorizeTraefikRequest(
|
||||
new NextRequest("http://localhost/api/traefik-auth/v1/feedback-records", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("authorizes record lookups through the shared FeedbackRecords authorizer", async () => {
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
|
||||
mockVerifyFeedbackRecordsGatewayToken.mockReturnValue({ userId: "user_1" });
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
forwardedMethod: "PATCH",
|
||||
forwardedUri: `/v1/feedback-records/${feedbackRecordId}`,
|
||||
headers: {
|
||||
authorization: "Bearer header.payload.signature",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetFeedbackRecordTenant).toHaveBeenCalledWith(feedbackRecordId);
|
||||
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId: "org_1",
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
workspaceId: "workspace_1",
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 401 for invalid explicit JWT instead of falling back to session cookies", async () => {
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
|
||||
mockGetProxySession.mockResolvedValue({
|
||||
userId: "user_1",
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
|
||||
headers: {
|
||||
authorization: "Bearer header.payload.signature",
|
||||
cookie: "next-auth.session-token=still-present",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockGetProxySession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
|
||||
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
|
||||
import { buildTraefikAllowResponse, parseTraefikRequestMetadata } from "./shared";
|
||||
|
||||
export const authorizeTraefikRequest = async (request: NextRequest): Promise<Response> => {
|
||||
const requestMetadata = parseTraefikRequestMetadata(request);
|
||||
if ("errorResponse" in requestMetadata) {
|
||||
return requestMetadata.errorResponse;
|
||||
}
|
||||
|
||||
return await authorizeGatewayRequest({
|
||||
request,
|
||||
originalRequest: requestMetadata.originalRequest,
|
||||
authorizers: gatewayRequestAuthorizers,
|
||||
requestId: request.headers.get("x-request-id") ?? request.headers.get("x-forwarded-for") ?? "unknown",
|
||||
buildAllowResponse: buildTraefikAllowResponse,
|
||||
unsupportedRouteMessage: "Unsupported Traefik auth route",
|
||||
});
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
|
||||
|
||||
const TRAEFIK_AUTH_PREFIX = "/api/traefik-auth";
|
||||
|
||||
const isTraefikAuthPath = (pathname: string): boolean =>
|
||||
pathname === TRAEFIK_AUTH_PREFIX || pathname.startsWith(`${TRAEFIK_AUTH_PREFIX}/`);
|
||||
|
||||
const buildForwardedRequestUrl = (request: NextRequest, forwardedUri: string): URL => {
|
||||
if (forwardedUri.startsWith("http://") || forwardedUri.startsWith("https://")) {
|
||||
return new URL(forwardedUri);
|
||||
}
|
||||
|
||||
const proto = request.headers.get("x-forwarded-proto") || "https";
|
||||
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || "traefik-auth.local";
|
||||
const normalizedUri = forwardedUri.startsWith("/") ? forwardedUri : `/${forwardedUri}`;
|
||||
|
||||
return new URL(normalizedUri, `${proto}://${host}`);
|
||||
};
|
||||
|
||||
export const buildTraefikAllowResponse = (): Response => new Response(null, { status: 200 });
|
||||
|
||||
export const parseTraefikRequestMetadata = (
|
||||
request: NextRequest
|
||||
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
|
||||
if (!isTraefikAuthPath(request.nextUrl.pathname)) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid Traefik auth request path"),
|
||||
};
|
||||
}
|
||||
|
||||
const forwardedMethod = request.headers.get("x-forwarded-method")?.trim();
|
||||
const forwardedUri = request.headers.get("x-forwarded-uri")?.trim();
|
||||
|
||||
if (!forwardedMethod || !forwardedUri) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Missing original request metadata"),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
originalRequest: {
|
||||
method: forwardedMethod.toUpperCase(),
|
||||
url: buildForwardedRequestUrl(request, forwardedUri),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid original request URI"),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -28,32 +28,6 @@ const SelectTrigger = React.forwardRef<
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
|
||||
{...props}>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
|
||||
{...props}>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
@@ -65,16 +39,18 @@ const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = R
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white text-slate-700 shadow-md animate-in fade-in-80 dark:bg-slate-700 dark:text-slate-300",
|
||||
position === "popper" &&
|
||||
"max-h-[var(--radix-select-content-available-height)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}>
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
@@ -122,8 +98,6 @@ export {
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
|
||||
@@ -37,8 +37,3 @@ The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (
|
||||
- **Development** (`docker-compose.dev.yml`): Hub uses the same local Postgres database and `HUB_API_KEY` defaults to `dev-api-key`. Cube is behind the `xm` profile, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. The Hub image is pinned to a semver tag (`hub` and `hub-migrate` share the same value); override `HUB_IMAGE_TAG` in the repo root `.env` to test a specific Hub release.
|
||||
|
||||
In development, Hub is exposed locally on port **8080**. When the `xm` profile is enabled, Cube is exposed on **4000** (with the Cube playground on **4001**). In production Docker Compose, Hub stays internal to the compose network at `http://hub:8080`; Cube also stays internal at `http://cube:4000` when enabled.
|
||||
|
||||
The one-click Traefik installer exposes Hub-backed FeedbackRecords on the Formbricks origin at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik uses Formbricks gateway auth, rewrites the v3
|
||||
path to Hub's `/v1/feedback-records`, injects `Authorization: Bearer ${HUB_API_KEY}` for Hub, and strips client
|
||||
API key/cookie headers before the Hub hop.
|
||||
|
||||
+2
-65
@@ -397,12 +397,6 @@ EOF
|
||||
print " - \"traefik.http.routers.formbricks.tls=true\""
|
||||
print " - \"traefik.http.routers.formbricks.tls.certresolver=default\""
|
||||
print " - \"traefik.http.services.formbricks.loadbalancer.server.port=3000\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.rule=Host(`" domain_name "`) && Path(`/api/v3/feedbackRecords/token`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.service=formbricks\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.priority=200\""
|
||||
if (hsts_enabled == "y") {
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.stsSeconds=31536000\""
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.forceSTSHeader=true\""
|
||||
@@ -411,10 +405,6 @@ EOF
|
||||
} else {
|
||||
print " - \"traefik.http.routers.formbricks_http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.formbricks_http.rule=Host(`" domain_name "`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.rule=Host(`" domain_name "`) && Path(`/api/v3/feedbackRecords/token`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.service=formbricks\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.priority=200\""
|
||||
}
|
||||
print $0
|
||||
} else {
|
||||
@@ -423,57 +413,6 @@ EOF
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Step 1b: Add FeedbackRecords gateway labels to the Hub service.
|
||||
awk -v domain_name="$domain_name" -v hsts_enabled="$hsts_enabled" '
|
||||
BEGIN { in_hub = 0; inserted = 0 }
|
||||
/^ hub:/ { in_hub = 1 }
|
||||
in_hub && /^ [A-Za-z0-9_-]+:/ && !/^ hub:/ { in_hub = 0 }
|
||||
{
|
||||
if (in_hub && !inserted && $0 ~ /^ environment:/) {
|
||||
print " labels:"
|
||||
print " - \"traefik.enable=true\""
|
||||
print " - \"traefik.http.services.feedback-records-hub.loadbalancer.server.port=8080\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.rule=Host(`" domain_name "`) && PathPrefix(`/api/v3/feedbackRecords`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.middlewares=feedback-records-auth,feedback-records-v3-rewrite,feedback-records-hub-headers\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.rule=Host(`" domain_name "`) && PathPrefix(`/v1/feedback-records`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.middlewares=feedback-records-auth,feedback-records-hub-headers\""
|
||||
if (hsts_enabled != "y") {
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.rule=Host(`" domain_name "`) && PathPrefix(`/api/v3/feedbackRecords`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.middlewares=feedback-records-auth,feedback-records-v3-rewrite,feedback-records-hub-headers\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.rule=Host(`" domain_name "`) && PathPrefix(`/v1/feedback-records`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.middlewares=feedback-records-auth,feedback-records-hub-headers\""
|
||||
}
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.address=http://formbricks:3000/api/traefik-auth/feedback-records\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.forwardbody=true\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.maxbodysize=1048576\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.preserverequestmethod=true\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-v3-rewrite.replacepathregex.regex=^/api/v3/feedbackRecords(.*)\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-v3-rewrite.replacepathregex.replacement=/v1/feedback-records$${1}\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.Authorization=Bearer ${HUB_API_KEY}\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.X-API-Key=\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.Cookie=\""
|
||||
inserted = 1
|
||||
}
|
||||
print
|
||||
}
|
||||
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Step 2: Ensure formbricks waits for minio-init to complete successfully (mapping depends_on)
|
||||
@@ -572,12 +511,11 @@ EOF
|
||||
if [[ $insert_traefik == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
traefik:
|
||||
image: "traefik:v3.6.4"
|
||||
image: "traefik:v2.11.31"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
- formbricks
|
||||
- hub
|
||||
- minio
|
||||
ports:
|
||||
- "80:80"
|
||||
@@ -602,12 +540,11 @@ EOF
|
||||
cat > "$services_snippet_file" << EOF
|
||||
|
||||
traefik:
|
||||
image: "traefik:v3.6.4"
|
||||
image: "traefik:v2.11.31"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
- formbricks
|
||||
- hub
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
title: "API Gateway"
|
||||
description: "Gateway auth architecture for proxied service APIs"
|
||||
icon: "route"
|
||||
---
|
||||
|
||||
### Gateway Model
|
||||
|
||||
Formbricks gateway auth is split into three layers:
|
||||
|
||||
- A shared gateway-auth core authenticates the caller, normalizes the original request, and dispatches to a
|
||||
service authorizer.
|
||||
- Provider adapters translate ingress-specific auth requests into the shared shape. Envoy uses
|
||||
`/api/envoy-auth/[...path]`; Traefik uses `/api/traefik-auth/[...path]`.
|
||||
- Service authorizers own service-specific authorization. FeedbackRecords is the first registered service authorizer.
|
||||
|
||||
### Tokens
|
||||
|
||||
Session-authenticated browser callers should use `/api/v3/gateway/token` with
|
||||
`{ "service": "feedbackRecords" }`. The token identifies the user for the gateway only; every proxied request
|
||||
still runs through gateway authorization. `/api/v3/feedbackRecords/token` remains a compatibility alias.
|
||||
|
||||
### Provider Adapters
|
||||
|
||||
Envoy and Traefik do not send auth subrequests in the same format, so they stay as thin adapters. Envoy derives the
|
||||
original path from the auth request path. Traefik derives it from `X-Forwarded-Method` and `X-Forwarded-Uri`.
|
||||
Both adapters reuse the same gateway-auth core and FeedbackRecords authorizer.
|
||||
@@ -309,7 +309,6 @@
|
||||
"group": "Technical Handbook",
|
||||
"pages": [
|
||||
"development/technical-handbook/overview",
|
||||
"development/technical-handbook/api-gateway",
|
||||
"development/technical-handbook/background-job-processing",
|
||||
"development/technical-handbook/cube-tenant-isolation",
|
||||
"development/technical-handbook/database-model",
|
||||
|
||||
@@ -109,7 +109,7 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
|
||||
<<: *environment
|
||||
|
||||
traefik:
|
||||
image: "traefik:v3.6.4"
|
||||
image: "traefik:v2.11.31"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
@@ -146,4 +146,4 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
|
||||
This setup ensures that Formbricks securely communicates using your own SSL certificate. 🚀
|
||||
|
||||
If you have any questions or require help, feel free to reach out to us on [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions). 😃[
|
||||
](https://formbricks.com/docs/developer-docs/rest-api)
|
||||
](https://formbricks.com/docs/developer-docs/rest-api)
|
||||
@@ -151,13 +151,6 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
enabled, Cube.js is internal too. The app reaches them through `http://hub:8080` and `http://cube:4000`.
|
||||
</Note>
|
||||
|
||||
<Info>
|
||||
If you use the one-click Traefik setup, FeedbackRecords are available on the Formbricks origin at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Custom Docker reverse proxies need equivalent wiring:
|
||||
run gateway auth against the Formbricks app, rewrite `/api/v3/feedbackRecords` to Hub's
|
||||
`/v1/feedback-records`, and inject `Authorization: Bearer <HUB_API_KEY>` only on the Hub-bound hop.
|
||||
</Info>
|
||||
|
||||
## Update
|
||||
|
||||
Please take a look at our [migration guide](/self-hosting/advanced/migration) for version specific steps to update Formbricks.
|
||||
|
||||
@@ -40,13 +40,6 @@ curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker
|
||||
finishes, add it manually. `HUB_API_URL` should normally stay at `http://hub:8080`.
|
||||
</Info>
|
||||
|
||||
<Info>
|
||||
The v5 one-click Traefik setup also exposes Hub-backed FeedbackRecords through Formbricks at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik calls the internal Formbricks gateway auth
|
||||
endpoint first, then forwards allowed requests to Hub with the generated `HUB_API_KEY`. Browser callers should
|
||||
request short-lived gateway tokens from `/api/v3/gateway/token` with `{ "service": "feedbackRecords" }`.
|
||||
</Info>
|
||||
|
||||
### Script Prompts
|
||||
|
||||
During installation, the script will prompt you to provide some details:
|
||||
|
||||
@@ -23,7 +23,9 @@ export const ZHubFieldType = z.enum([
|
||||
]);
|
||||
export type THubFieldType = z.infer<typeof ZHubFieldType>;
|
||||
|
||||
// Hub target fields for mapping
|
||||
// Hub target fields for mapping.
|
||||
// `response_value` is a CSV-only synthetic id stored in ConnectorFieldMapping; csv-transform.ts
|
||||
// resolves it to the appropriate value_* target before any Hub write — the Hub never sees it.
|
||||
export const ZHubTargetField = z.enum([
|
||||
"collected_at",
|
||||
"source_type",
|
||||
@@ -43,6 +45,7 @@ export const ZHubTargetField = z.enum([
|
||||
"language",
|
||||
"user_id",
|
||||
"submission_id",
|
||||
"response_value",
|
||||
]);
|
||||
export type THubTargetField = z.infer<typeof ZHubTargetField>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user