mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 11:29:31 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b656e94f07 | |||
| 5f5860cb23 | |||
| b2a95d4cee | |||
| 64b4e18c5a | |||
| daae319c7a |
@@ -3521,6 +3521,9 @@ checksums:
|
||||
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
|
||||
@@ -3536,10 +3539,12 @@ 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
|
||||
@@ -3547,6 +3552,8 @@ checksums:
|
||||
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
|
||||
|
||||
@@ -40,6 +40,8 @@ const mockSurvey = {
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockTenantId = "cmp2f6428000504la7iyh87h1";
|
||||
|
||||
const mockResponse = {
|
||||
id: "resp-1",
|
||||
createdAt: NOW,
|
||||
@@ -84,13 +86,18 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
|
||||
test("returns empty array when response has no data", () => {
|
||||
const emptyResponse = { ...mockResponse, data: null } as unknown as TResponse;
|
||||
const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings);
|
||||
const result = transformResponseToFeedbackRecords(emptyResponse, mockSurvey, allMappings, mockTenantId);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty array when no mappings match the survey", () => {
|
||||
const otherSurveyMappings = allMappings.map((m) => ({ ...m, surveyId: "other-survey" }));
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, otherSurveyMappings);
|
||||
const result = transformResponseToFeedbackRecords(
|
||||
mockResponse,
|
||||
mockSurvey,
|
||||
otherSurveyMappings,
|
||||
mockTenantId
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -100,7 +107,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-text": "" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -113,14 +120,14 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
createMapping({ elementId: "el-text", hubFieldType: "text" }),
|
||||
createMapping({ elementId: "el-nps", hubFieldType: "nps" }),
|
||||
];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].field_id).toBe("el-nps");
|
||||
});
|
||||
|
||||
test("transforms text field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
source_type: "formbricks_survey",
|
||||
@@ -137,7 +144,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
|
||||
test("transforms nps field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_number).toBe(9);
|
||||
expect(result[0].field_type).toBe("nps");
|
||||
@@ -145,28 +152,28 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
|
||||
test("transforms rating field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-rating", hubFieldType: "rating" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_number).toBe(4);
|
||||
});
|
||||
|
||||
test("transforms date field to ISO string", () => {
|
||||
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_date).toBe(new Date("2026-01-15").toISOString());
|
||||
});
|
||||
|
||||
test("transforms boolean field correctly", () => {
|
||||
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_boolean).toBe(true);
|
||||
});
|
||||
|
||||
test("transforms categorical (multi-select) field to comma-separated text", () => {
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_text).toBe("feat-a, feat-b");
|
||||
});
|
||||
@@ -175,13 +182,13 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
const mappings = [
|
||||
createMapping({ elementId: "el-text", hubFieldType: "text", customFieldLabel: "Custom Label" }),
|
||||
];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].field_label).toBe("Custom Label");
|
||||
});
|
||||
|
||||
test("sets collected_at from response createdAt", () => {
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].collected_at).toBe(NOW.toISOString());
|
||||
});
|
||||
|
||||
@@ -189,7 +196,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
const updatedAt = new Date("2026-02-25T10:00:00.000Z");
|
||||
const response = { ...mockResponse, createdAt: undefined, updatedAt } as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].collected_at).toBe(updatedAt.toISOString());
|
||||
});
|
||||
|
||||
@@ -199,7 +206,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
createdAt: "2026-02-26T10:00:00.000Z",
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].collected_at).toBe("2026-02-26T10:00:00.000Z");
|
||||
});
|
||||
|
||||
@@ -209,28 +216,22 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
expect(result[0].tenant_id).toBe("tenant-abc");
|
||||
});
|
||||
|
||||
test("omits tenant_id when not provided", () => {
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result[0].tenant_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test("omits language when response language is 'default'", () => {
|
||||
const response = { ...mockResponse, language: "default" } as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].language).toBeUndefined();
|
||||
});
|
||||
|
||||
test("omits user_id when contact has no userId", () => {
|
||||
const response = { ...mockResponse, contact: null } as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].user_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test("transforms all mappings in a single call", () => {
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings);
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings, mockTenantId);
|
||||
expect(result).toHaveLength(6);
|
||||
const fieldIds = result.map((r) => r.field_id);
|
||||
expect(fieldIds).toEqual(["el-text", "el-nps", "el-rating", "el-date", "el-bool", "el-multi"]);
|
||||
@@ -246,7 +247,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-bare": "some text" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-bare", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, survey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId);
|
||||
expect(result[0].field_label).toBe("Untitled");
|
||||
});
|
||||
|
||||
@@ -257,7 +258,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-nps": "7" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_number).toBe(7);
|
||||
});
|
||||
|
||||
@@ -267,7 +268,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-nps": "not-a-number" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-nps", hubFieldType: "nps" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_number).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -277,7 +278,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-text": { nested: "value" } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_text).toBe(JSON.stringify({ nested: "value" }));
|
||||
});
|
||||
|
||||
@@ -287,7 +288,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-date": "not-a-date" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-date", hubFieldType: "date" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_date).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -297,7 +298,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-bool": "1" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_boolean).toBe(true);
|
||||
});
|
||||
|
||||
@@ -307,7 +308,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-bool": "false" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-bool", hubFieldType: "boolean" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_boolean).toBe(false);
|
||||
});
|
||||
|
||||
@@ -317,7 +318,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-text": ["a", "b", "c"] },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_text).toBe("a, b, c");
|
||||
});
|
||||
|
||||
@@ -327,8 +328,266 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
data: { "el-multi": "single-choice" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings);
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_text).toBe("single-choice");
|
||||
});
|
||||
|
||||
test("JSON-stringifies object value for categorical field (matrix/ranking responses)", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-multi": { row1: "col1", row2: "col2" } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_text).toBe(JSON.stringify({ row1: "col1", row2: "col2" }));
|
||||
expect(result[0].value_text).not.toBe("[object Object]");
|
||||
});
|
||||
|
||||
test("creates a record for a ranking response (string array)", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-multi": ["LabelA", "LabelB", "LabelC"] },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].field_id).toBe("el-multi");
|
||||
expect(result[0].value_text).toBe("LabelA, LabelB, LabelC");
|
||||
});
|
||||
|
||||
test("creates a record for an empty ranking response (empty array)", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-multi": [] },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value_text).toBe("");
|
||||
});
|
||||
|
||||
test("JSON-stringifies object value for unknown hubFieldType (default branch)", () => {
|
||||
const response = {
|
||||
...mockResponse,
|
||||
data: { "el-multi": { row1: "col1" } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [
|
||||
createMapping({
|
||||
elementId: "el-multi",
|
||||
hubFieldType: "unknown-type" as TConnectorFormbricksMapping["hubFieldType"],
|
||||
}),
|
||||
];
|
||||
const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId);
|
||||
expect(result[0].value_text).toBe(JSON.stringify({ row1: "col1" }));
|
||||
expect(result[0].value_text).not.toBe("[object Object]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix expansion", () => {
|
||||
const matrixSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Matrix Survey",
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-matrix",
|
||||
type: "matrix",
|
||||
headline: { default: "Rate each feature" },
|
||||
rows: [
|
||||
{ id: "row-1", label: { default: "Speed" } },
|
||||
{ id: "row-2", label: { default: "Quality" } },
|
||||
],
|
||||
columns: [
|
||||
{ id: "col-1", label: { default: "Good" } },
|
||||
{ id: "col-2", label: { default: "Bad" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
test("emits one record per answered row with shared field_group_id", () => {
|
||||
const response = {
|
||||
id: "resp-matrix",
|
||||
createdAt: NOW,
|
||||
data: { "el-matrix": { Speed: "Good", Quality: "Bad" } },
|
||||
language: "default",
|
||||
contact: { userId: "user-42" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((r) => r.field_group_id === "el-matrix")).toBe(true);
|
||||
expect(result.every((r) => r.field_group_label === "Rate each feature")).toBe(true);
|
||||
expect(result.every((r) => r.submission_id === "resp-matrix")).toBe(true);
|
||||
expect(result.every((r) => r.metadata?.question_type === "matrix")).toBe(true);
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
field_id: "el-matrix__row-1",
|
||||
field_label: "Speed",
|
||||
field_type: "categorical",
|
||||
value_text: "Good",
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
field_id: "el-matrix__row-2",
|
||||
field_label: "Quality",
|
||||
value_text: "Bad",
|
||||
});
|
||||
});
|
||||
|
||||
test("skips matrix rows with empty cell value", () => {
|
||||
const response = {
|
||||
id: "resp-matrix-partial",
|
||||
createdAt: NOW,
|
||||
data: { "el-matrix": { Speed: "Good", Quality: "" } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].field_id).toBe("el-matrix__row-1");
|
||||
});
|
||||
|
||||
test("skips matrix rows whose label does not match any row choice", () => {
|
||||
const response = {
|
||||
id: "resp-matrix-stale",
|
||||
createdAt: NOW,
|
||||
data: { "el-matrix": { "Old Row Label": "Good", Quality: "Bad" } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].field_id).toBe("el-matrix__row-2");
|
||||
});
|
||||
|
||||
test("JSON-stringifies non-string matrix cell value (regression for ENG-891)", () => {
|
||||
const cellObject = { a: 1 };
|
||||
const response = {
|
||||
id: "resp-matrix-obj",
|
||||
createdAt: NOW,
|
||||
data: { "el-matrix": { Speed: cellObject } },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
field_id: "el-matrix__row-1",
|
||||
field_label: "Speed",
|
||||
field_group_id: "el-matrix",
|
||||
field_group_label: "Rate each feature",
|
||||
metadata: { question_type: "matrix" },
|
||||
value_text: JSON.stringify(cellObject),
|
||||
});
|
||||
expect(result[0].value_text).not.toBe("[object Object]");
|
||||
});
|
||||
|
||||
test("emits no records for empty matrix response", () => {
|
||||
const response = {
|
||||
id: "resp-empty",
|
||||
createdAt: NOW,
|
||||
data: { "el-matrix": {} },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-matrix", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, matrixSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ranking expansion", () => {
|
||||
const rankingSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Ranking Survey",
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-ranking",
|
||||
type: "ranking",
|
||||
headline: { default: "Rank these features" },
|
||||
choices: [
|
||||
{ id: "ch-1", label: { default: "Reports" } },
|
||||
{ id: "ch-2", label: { default: "Dashboards" } },
|
||||
{ id: "ch-3", label: { default: "Alerts" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
test("emits one record per ranked item with rank as value_number", () => {
|
||||
const response = {
|
||||
id: "resp-ranking",
|
||||
createdAt: NOW,
|
||||
data: { "el-ranking": ["Dashboards", "Reports", "Alerts"] },
|
||||
language: "default",
|
||||
contact: { userId: "user-42" },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.every((r) => r.field_group_id === "el-ranking")).toBe(true);
|
||||
expect(result.every((r) => r.field_group_label === "Rank these features")).toBe(true);
|
||||
expect(result.every((r) => r.field_type === "number")).toBe(true);
|
||||
expect(result.every((r) => r.metadata?.question_type === "ranking")).toBe(true);
|
||||
expect(result.every((r) => r.metadata?.total_items === 3)).toBe(true);
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
field_id: "el-ranking__ch-2",
|
||||
field_label: "Dashboards",
|
||||
value_number: 1,
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
field_id: "el-ranking__ch-1",
|
||||
field_label: "Reports",
|
||||
value_number: 2,
|
||||
});
|
||||
expect(result[2]).toMatchObject({
|
||||
field_id: "el-ranking__ch-3",
|
||||
field_label: "Alerts",
|
||||
value_number: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("emits no records for empty ranking response", () => {
|
||||
const response = {
|
||||
id: "resp-empty",
|
||||
createdAt: NOW,
|
||||
data: { "el-ranking": [] },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips ranking items whose label does not match any choice", () => {
|
||||
const response = {
|
||||
id: "resp-ranking-stale",
|
||||
createdAt: NOW,
|
||||
data: { "el-ranking": ["Reports", "Removed Option"] },
|
||||
} as unknown as TResponse;
|
||||
const mappings = [createMapping({ elementId: "el-ranking", hubFieldType: "categorical" })];
|
||||
|
||||
const result = transformResponseToFeedbackRecords(response, rankingSurvey, mappings, mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].field_id).toBe("el-ranking__ch-1");
|
||||
expect(result[0].value_number).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import "server-only";
|
||||
import { TConnectorFormbricksMapping, THubFieldType } from "@formbricks/types/connector";
|
||||
import { TResponse, TResponseData, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import type {
|
||||
TSurveyElement,
|
||||
TSurveyElementChoice,
|
||||
TSurveyMatrixElement,
|
||||
TSurveyMatrixElementChoice,
|
||||
TSurveyRankingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
@@ -14,6 +21,18 @@ const getHeadlineFromElement = (element?: TSurveyElement): string => {
|
||||
return getTextContent(raw) || "Untitled";
|
||||
};
|
||||
|
||||
const getChoiceLabel = (choice: { label: TSurveyElementChoice["label"] }, language: string): string => {
|
||||
return getTextContent(getLocalizedValue(choice.label, language));
|
||||
};
|
||||
|
||||
const findChoiceByLabel = <T extends { id: string; label: TSurveyElementChoice["label"] }>(
|
||||
choices: T[],
|
||||
label: string,
|
||||
language: string
|
||||
): T | undefined => {
|
||||
return choices.find((choice) => getChoiceLabel(choice, language) === label);
|
||||
};
|
||||
|
||||
const toIsoTimestamp = (value: unknown): string | null => {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
@@ -83,16 +102,114 @@ const convertValueToHubFields = (
|
||||
case "categorical":
|
||||
if (typeof value === "string") return { value_text: value };
|
||||
if (Array.isArray(value)) return { value_text: value.join(", ") };
|
||||
if (typeof value === "object") return { value_text: JSON.stringify(value) };
|
||||
return { value_text: String(value) };
|
||||
|
||||
default:
|
||||
return { value_text: typeof value === "string" ? value : String(value) };
|
||||
if (typeof value === "string") return { value_text: value };
|
||||
if (Array.isArray(value)) return { value_text: value.join(", ") };
|
||||
if (typeof value === "object") return { value_text: JSON.stringify(value) };
|
||||
return { value_text: String(value) };
|
||||
}
|
||||
};
|
||||
|
||||
type BaseRecordFields = Pick<
|
||||
FeedbackRecordCreateParams,
|
||||
"collected_at" | "source_type" | "submission_id" | "tenant_id" | "source_id" | "source_name"
|
||||
> & {
|
||||
language?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
|
||||
const buildBaseFields = (
|
||||
response: TResponse,
|
||||
survey: Pick<TSurvey, "id" | "name">,
|
||||
tenantId: string
|
||||
): BaseRecordFields => ({
|
||||
collected_at: getCollectedAt(response),
|
||||
source_type: "formbricks_survey",
|
||||
submission_id: response.id,
|
||||
tenant_id: tenantId,
|
||||
source_id: survey.id,
|
||||
source_name: survey.name,
|
||||
...(response.language && response.language !== "default" ? { language: response.language } : {}),
|
||||
...(response.contact?.userId ? { user_id: response.contact.userId } : {}),
|
||||
});
|
||||
|
||||
const expandMatrixToRecords = (
|
||||
element: TSurveyMatrixElement,
|
||||
mapping: TConnectorFormbricksMapping,
|
||||
value: TResponseDataValue,
|
||||
baseFields: BaseRecordFields
|
||||
): FeedbackRecordCreateParams[] => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
|
||||
const language = baseFields.language ?? "default";
|
||||
const groupLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
|
||||
const records: FeedbackRecordCreateParams[] = [];
|
||||
|
||||
for (const [rowLabel, columnLabel] of Object.entries(value)) {
|
||||
if (columnLabel === undefined || columnLabel === null || columnLabel === "") continue;
|
||||
|
||||
const row = findChoiceByLabel<TSurveyMatrixElementChoice>(element.rows, rowLabel, language);
|
||||
if (!row) continue;
|
||||
|
||||
const valueFields = convertValueToHubFields(columnLabel as TResponseDataValue, mapping.hubFieldType);
|
||||
|
||||
records.push({
|
||||
...baseFields,
|
||||
field_id: `${element.id}__${row.id}`,
|
||||
field_type: mapping.hubFieldType,
|
||||
field_label: getChoiceLabel(row, "default"),
|
||||
field_group_id: element.id,
|
||||
field_group_label: groupLabel,
|
||||
metadata: { question_type: "matrix" },
|
||||
...valueFields,
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
};
|
||||
|
||||
const expandRankingToRecords = (
|
||||
element: TSurveyRankingElement,
|
||||
mapping: TConnectorFormbricksMapping,
|
||||
value: TResponseDataValue,
|
||||
baseFields: BaseRecordFields
|
||||
): FeedbackRecordCreateParams[] => {
|
||||
if (!Array.isArray(value) || value.length === 0) return [];
|
||||
|
||||
const language = baseFields.language ?? "default";
|
||||
const groupLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
|
||||
const records: FeedbackRecordCreateParams[] = [];
|
||||
|
||||
value.forEach((itemLabel, index) => {
|
||||
if (typeof itemLabel !== "string" || itemLabel === "") return;
|
||||
|
||||
const choice = findChoiceByLabel<TSurveyElementChoice>(element.choices, itemLabel, language);
|
||||
if (!choice) return;
|
||||
|
||||
records.push({
|
||||
...baseFields,
|
||||
field_id: `${element.id}__${choice.id}`,
|
||||
field_type: "number",
|
||||
field_label: getChoiceLabel(choice, "default"),
|
||||
field_group_id: element.id,
|
||||
field_group_label: groupLabel,
|
||||
metadata: { question_type: "ranking", total_items: value.length },
|
||||
value_number: index + 1,
|
||||
});
|
||||
});
|
||||
|
||||
return records;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform a Formbricks survey response into FeedbackRecord payloads.
|
||||
* Called from the pipeline handler when a response is created/finished.
|
||||
*
|
||||
* Matrix and ranking questions expand into one record per row/item, sharing a
|
||||
* field_group_id so Hub analytics can aggregate across them.
|
||||
*/
|
||||
export function transformResponseToFeedbackRecords(
|
||||
response: TResponse,
|
||||
@@ -106,31 +223,35 @@ export function transformResponseToFeedbackRecords(
|
||||
const surveyMappings = mappings.filter((m) => m.surveyId === survey.id);
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementMap = new Map(elements.map((el) => [el.id, el]));
|
||||
const baseFields = buildBaseFields(response, survey, tenantId);
|
||||
const feedbackRecords: FeedbackRecordCreateParams[] = [];
|
||||
|
||||
for (const mapping of surveyMappings) {
|
||||
const value = extractResponseValue(responseData, mapping.elementId);
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
|
||||
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(elementMap.get(mapping.elementId));
|
||||
const element = elementMap.get(mapping.elementId);
|
||||
|
||||
if (element?.type === TSurveyElementTypeEnum.Matrix) {
|
||||
feedbackRecords.push(...expandMatrixToRecords(element, mapping, value, baseFields));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element?.type === TSurveyElementTypeEnum.Ranking) {
|
||||
feedbackRecords.push(...expandRankingToRecords(element, mapping, value, baseFields));
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(element);
|
||||
const valueFields = convertValueToHubFields(value, mapping.hubFieldType);
|
||||
|
||||
const feedbackRecord = {
|
||||
collected_at: getCollectedAt(response),
|
||||
source_type: "formbricks_survey",
|
||||
submission_id: response.id,
|
||||
tenant_id: tenantId,
|
||||
feedbackRecords.push({
|
||||
...baseFields,
|
||||
field_id: mapping.elementId,
|
||||
field_type: mapping.hubFieldType,
|
||||
source_id: survey.id,
|
||||
source_name: survey.name,
|
||||
field_label: fieldLabel,
|
||||
...(response.language && response.language !== "default" ? { language: response.language } : {}),
|
||||
...(response.contact?.userId ? { user_id: response.contact.userId } : {}),
|
||||
...valueFields,
|
||||
};
|
||||
|
||||
feedbackRecords.push(feedbackRecord as FeedbackRecordCreateParams);
|
||||
});
|
||||
}
|
||||
|
||||
return feedbackRecords;
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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í",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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": "ここにフィールドをドロップ",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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": "フィードバックレコードフィールド",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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": "Перетащи сюда поле",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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": "Поля записи отзыва",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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ı",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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": "将字段拖到这里",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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": "反馈记录字段",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -3681,6 +3681,9 @@
|
||||
"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": "請將欄位拖曳到這裡",
|
||||
@@ -3696,10 +3699,12 @@
|
||||
"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": "回饋紀錄欄位",
|
||||
@@ -3707,6 +3712,8 @@
|
||||
"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}",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,8 @@ 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 +31,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;
|
||||
|
||||
@@ -87,8 +91,40 @@ export const FeedbackRecordsTable = ({
|
||||
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | 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 +196,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 +236,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 +300,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 +372,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 +391,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 +407,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 +443,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}
|
||||
@@ -363,12 +473,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 +493,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 +504,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>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -425,7 +426,7 @@ export const CreateConnectorModal = ({
|
||||
<DialogDescription>{getDialogDescription(currentStep, selectedType, t)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<DialogBody className="min-h-0 min-w-0 overflow-y-auto py-4">
|
||||
{currentStep === "selectType" && (
|
||||
<ConnectorTypeSelector
|
||||
selectedType={selectedType}
|
||||
@@ -593,7 +594,7 @@ export const CreateConnectorModal = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
{currentStep === "mapping" && (
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -186,6 +186,7 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
|
||||
| hub.embeddings.autoscaling.minReplicas | int | `1` | |
|
||||
| hub.embeddings.baseUrl | string | `""` | Defaults to the internal TEI service URL ending in `/v1`. |
|
||||
| hub.embeddings.enabled | bool | `false` | |
|
||||
| hub.embeddings.extraArgs | list | `["--dtype","float16"]` | Additional args appended to the generated TEI args. |
|
||||
| hub.embeddings.huggingFace.existingSecret | string | `""` | |
|
||||
| hub.embeddings.huggingFace.token | string | `""` | |
|
||||
| hub.embeddings.huggingFace.tokenKey | string | `"HF_TOKEN"` | |
|
||||
|
||||
@@ -782,7 +782,10 @@ hub:
|
||||
# When empty, the chart renders TEI args from model, servedModelName, port,
|
||||
# revision, and persistence.mountPath. Set this to fully override args.
|
||||
args: []
|
||||
extraArgs: []
|
||||
# Keeps the default GTE CPU runtime within the default memory budget.
|
||||
extraArgs:
|
||||
- --dtype
|
||||
- float16
|
||||
env: {}
|
||||
|
||||
port: 8080
|
||||
|
||||
Reference in New Issue
Block a user