mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-18 06:52:01 -05:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0493f58976 | |||
| 3f67305500 | |||
| e4f63a23fe | |||
| fab773eeca | |||
| 7ef265dec2 | |||
| 74d2eb445f | |||
| 11a2dcf223 | |||
| b7a72ce037 | |||
| 86241c47a9 | |||
| a9e6bd440d | |||
| 7c53e7deca | |||
| eaf6c889f8 | |||
| 365c8e88b7 | |||
| 3486ab67d7 | |||
| defd333d97 | |||
| 0e7ea4637d | |||
| 0475232bad | |||
| 7b61e3b9bd | |||
| b656e94f07 | |||
| d73e342028 | |||
| 0a09b68e08 | |||
| 5f5860cb23 | |||
| b2a95d4cee | |||
| 64b4e18c5a | |||
| ae9c1e499a | |||
| 0a4e32b848 | |||
| daae319c7a | |||
| 7d77ed04de | |||
| 5b70c99eb3 | |||
| 10c09f00a8 | |||
| 602ffd5bba | |||
| 5f4f133dcb | |||
| 037b005d48 | |||
| ddd2d5e983 | |||
| 6777b284b3 | |||
| c6282632e0 | |||
| f84c409bc4 | |||
| 98b475a2a4 | |||
| c48474b943 | |||
| 3c0d1e3fd7 | |||
| 1f7a496967 | |||
| 99e378ae2e | |||
| c6e39c3103 | |||
| 19472ca9d3 | |||
| 43feff0009 | |||
| 507cec6958 | |||
| d874b65be9 | |||
| c159af1a26 | |||
| 3ce2ef6cf4 | |||
| baae6335c9 | |||
| 8e443db1f6 | |||
| 18cd9afc2c | |||
| 680e1e1593 | |||
| 04bfdeef69 | |||
| 8a1db7b6aa | |||
| 1d22fe2da6 | |||
| d8a119712c | |||
| 50089eeca4 | |||
| d8fe832f5f | |||
| 1c904e2243 | |||
| 4dbecc2d58 | |||
| 72f4e93432 | |||
| 9007502804 | |||
| d84589452c | |||
| 43aaed3923 | |||
| 550bfc6a6c | |||
| 2c22b00ec6 | |||
| d64fb546d3 | |||
| d192fbf839 | |||
| c5d52df9b7 | |||
| 550e859a2d |
@@ -43,6 +43,7 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -151,7 +152,17 @@ export const MainNavigation = ({
|
||||
},
|
||||
{
|
||||
id: "unify-feedback",
|
||||
name: t("workspace.unify.unify_feedback"),
|
||||
name: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>{t("workspace.unify.unify_feedback")}</span>
|
||||
<Badge
|
||||
text="Beta"
|
||||
type="gray"
|
||||
size="tiny"
|
||||
className="normal-case text-[10px] font-semibold tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
name: t("workspace.unify.feedback_records"),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { authorizeTraefikRequest } from "@/modules/traefik-auth/service";
|
||||
|
||||
const handler = async (request: NextRequest): Promise<Response> => {
|
||||
return await authorizeTraefikRequest(request);
|
||||
};
|
||||
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
export const PUT = handler;
|
||||
export const PATCH = handler;
|
||||
export const DELETE = handler;
|
||||
export const HEAD = handler;
|
||||
export const OPTIONS = handler;
|
||||
@@ -78,6 +78,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
variables: true,
|
||||
type: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
languages: {
|
||||
select: {
|
||||
default: true,
|
||||
|
||||
@@ -4925,6 +4925,7 @@ export const previewSurvey = (workspaceName: string, t: TFunction): TSurvey => {
|
||||
languages: [],
|
||||
triggers: [],
|
||||
showLanguageSwitch: false,
|
||||
autoSelectLanguage: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
|
||||
+37
-5
@@ -1604,6 +1604,7 @@ checksums:
|
||||
workspace/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
|
||||
workspace/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
|
||||
workspace/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
|
||||
workspace/analysis/charts/already_on_dashboard: c2cee946860c71a71cf03392b2d1fc3a
|
||||
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
|
||||
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
|
||||
workspace/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
|
||||
@@ -1665,22 +1666,37 @@ checksums:
|
||||
workspace/analysis/charts/failed_to_load_dashboards: 876c54d9cc69ceda6f808231e2557eb2
|
||||
workspace/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
|
||||
workspace/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
|
||||
workspace/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
|
||||
workspace/analysis/charts/field_label_ces_average: 3bf598396ea490f3a2bdccf0c94b6aa0
|
||||
workspace/analysis/charts/field_label_ces_count: 4dc90d50a8e05dd9ba4a9e356926e0cb
|
||||
workspace/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
|
||||
workspace/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
|
||||
workspace/analysis/charts/field_label_created_at: 9ce495d7fc74e1a2ae86c07206a3e531
|
||||
workspace/analysis/charts/field_label_csat_average: f7c43dac56267f832fbebd6d18efdef1
|
||||
workspace/analysis/charts/field_label_csat_count: 30c1ab12748b503ffee399ed326e0562
|
||||
workspace/analysis/charts/field_label_csat_dissatisfied_count: 7f68b2c8302bde5cd93ba86d2163f86d
|
||||
workspace/analysis/charts/field_label_csat_neutral_count: 19edae275784e8d53dd45003a2e8971a
|
||||
workspace/analysis/charts/field_label_csat_satisfied_count: 78fcabc88da4b22171be149b1be509bd
|
||||
workspace/analysis/charts/field_label_csat_score: 89a87f1069641bba10607d5b407cb0aa
|
||||
workspace/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
|
||||
workspace/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
|
||||
workspace/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
|
||||
workspace/analysis/charts/field_label_language: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
workspace/analysis/charts/field_label_nps_average: 4a61877a06cb8e64a0a8375dd058a548
|
||||
workspace/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
|
||||
workspace/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
|
||||
workspace/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
|
||||
workspace/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
|
||||
workspace/analysis/charts/field_label_question: 0576462ce60d4263d7c482463fcc9547
|
||||
workspace/analysis/charts/field_label_question_group: b007e2cfd1262272de3260f8d14d5833
|
||||
workspace/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
|
||||
workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
|
||||
workspace/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
|
||||
workspace/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
|
||||
workspace/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
|
||||
workspace/analysis/charts/field_label_unique_respondents: e340f09af176927f1ed16719ee304274
|
||||
workspace/analysis/charts/field_label_unique_responses: d9ffcc58f72b9fdb143027703371f22b
|
||||
workspace/analysis/charts/field_label_updated_at: a3730393cce5adfd9e50123d96640fd6
|
||||
workspace/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
|
||||
workspace/analysis/charts/field_label_value_boolean: bbdcd3f46954b6304b9069e94e1371ab
|
||||
workspace/analysis/charts/field_label_value_date: c8d705d1975affc01c002324725fec3f
|
||||
workspace/analysis/charts/field_label_value_number: 1f14da79d14bd7b1c2324141f4470675
|
||||
workspace/analysis/charts/field_label_value_text: e097a597cc507c716401ad18255de578
|
||||
workspace/analysis/charts/filter_data: 05cc68ed2896feef60bbe3829cd9063d
|
||||
workspace/analysis/charts/filters: acf5accc113ff3c1992688058576732c
|
||||
workspace/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
|
||||
@@ -1743,6 +1759,7 @@ checksums:
|
||||
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
|
||||
workspace/analysis/charts/update_chart: 7d9223335d9f0c5938ec30356d7034a9
|
||||
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
|
||||
workspace/analysis/dashboards/chart_removed: 1ce20b8ee0b56bcd7d6fea2b5c1ae9fd
|
||||
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
|
||||
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
|
||||
workspace/analysis/dashboards/charts_added_to_dashboard: 917abab80231adf5af51e812352fc77b
|
||||
@@ -3520,6 +3537,10 @@ 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/delete_source_confirmation: 43b048de6338be1757ade6bf5029b010
|
||||
workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941
|
||||
workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e
|
||||
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
|
||||
@@ -3529,10 +3550,18 @@ checksums:
|
||||
workspace/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
|
||||
workspace/unify/enter_value: 4f068bb59617975c1e546218373122cd
|
||||
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
|
||||
workspace/unify/error_connector_field_mapping_duplicate: 4e507ae4a99f53dfa75d849d32566bf2
|
||||
workspace/unify/error_connector_formbricks_mapping_duplicate: 48524e22fa33bd0b2829f7aea49c711b
|
||||
workspace/unify/error_connector_name_duplicate: 56a1a05212e87dff21261d1db90fdb11
|
||||
workspace/unify/error_connector_name_required: b2d5f79f6126e23128d7bef0c1736ff2
|
||||
workspace/unify/error_connector_questions_required: d7d8e388959ab83a9195ba63bebf6516
|
||||
workspace/unify/error_connector_survey_required: 1f49086dfb874307aae1136e88c3d514
|
||||
workspace/unify/failed_to_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
|
||||
@@ -3540,6 +3569,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
|
||||
@@ -3576,6 +3607,7 @@ checksums:
|
||||
workspace/unify/no_feedback_directory_linked_member_description: e799b17da03899cabd125c3cf672c10d
|
||||
workspace/unify/no_feedback_directory_linked_title: b01a53d508aeeb1c8ad080ee07efcd04
|
||||
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
|
||||
workspace/unify/no_formbricks_surveys_available_description: 2218334806910168bdfb6f283f31b963
|
||||
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
|
||||
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
|
||||
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
|
||||
@@ -114,6 +114,28 @@ describe("importHistoricalResponses", () => {
|
||||
expect(result.failures).toBe(1);
|
||||
});
|
||||
|
||||
test("counts 409 duplicates as skipped, not failures", async () => {
|
||||
const mockResponses = [{ id: "r1" }, { id: "r2" }, { id: "r3" }];
|
||||
getResponses.mockResolvedValueOnce(mockResponses as never);
|
||||
getResponses.mockResolvedValueOnce([]);
|
||||
|
||||
transformResponseToFeedbackRecords.mockReturnValue([{ field: "record" }] as never);
|
||||
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: { id: "fb1" }, error: null },
|
||||
{ data: null, error: { status: 409, message: "Conflict", detail: "duplicate" } },
|
||||
{ data: null, error: { status: 500, message: "Server error", detail: "boom" } },
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await importHistoricalResponses(mockConnector, mockSurvey);
|
||||
|
||||
expect(result.successes).toBe(1);
|
||||
expect(result.failures).toBe(1);
|
||||
expect(result.skipped).toBe(1);
|
||||
});
|
||||
|
||||
test("paginates through responses in batches", async () => {
|
||||
const batch1 = Array.from({ length: 50 }, (_, i) => ({ id: `r${i}` }));
|
||||
const batch2 = [{ id: "r50" }];
|
||||
|
||||
@@ -18,6 +18,7 @@ const processBatch = async (
|
||||
): Promise<TImportResult> => {
|
||||
let successes = 0;
|
||||
let failures = 0;
|
||||
let duplicates = 0;
|
||||
const expectedRecords = responses.length * mappings.length;
|
||||
|
||||
const allRecords = responses.flatMap((response) =>
|
||||
@@ -27,10 +28,12 @@ const processBatch = async (
|
||||
if (allRecords.length > 0) {
|
||||
const { results } = await createFeedbackRecordsBatch(allRecords);
|
||||
successes = results.filter((r) => r.data !== null).length;
|
||||
failures = results.filter((r) => r.error !== null).length;
|
||||
duplicates = results.filter((r) => r.error?.status === 409).length;
|
||||
failures = results.filter((r) => r.error !== null && r.error.status !== 409).length;
|
||||
}
|
||||
|
||||
return { successes, failures, skipped: expectedRecords - allRecords.length };
|
||||
const unmappedSkipped = expectedRecords - allRecords.length;
|
||||
return { successes, failures, skipped: unmappedSkipped + duplicates };
|
||||
};
|
||||
|
||||
export const importHistoricalResponses = async (
|
||||
|
||||
@@ -386,11 +386,12 @@ describe("createConnectorWithMappings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
test("throws CONNECTOR_NAME_DUPLICATE on Connector name unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "name"] },
|
||||
})
|
||||
);
|
||||
|
||||
@@ -400,7 +401,43 @@ describe("createConnectorWithMappings", () => {
|
||||
type: "formbricks_survey",
|
||||
feedbackDirectoryId: FRD_ID,
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
).rejects.toThrow(new InvalidInputError("CONNECTOR_NAME_DUPLICATE"));
|
||||
});
|
||||
|
||||
test("throws CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE on mapping unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "connectorId", "surveyId", "elementId"] },
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
createConnectorWithMappings(ENV_ID, {
|
||||
name: "Dup mapping",
|
||||
type: "formbricks_survey",
|
||||
feedbackDirectoryId: FRD_ID,
|
||||
})
|
||||
).rejects.toThrow(new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE"));
|
||||
});
|
||||
|
||||
test("throws CONNECTOR_FIELD_MAPPING_DUPLICATE on field mapping unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "connectorId", "sourceFieldId", "targetFieldId"] },
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
createConnectorWithMappings(ENV_ID, {
|
||||
name: "Dup field mapping",
|
||||
type: "csv",
|
||||
feedbackDirectoryId: FRD_ID,
|
||||
})
|
||||
).rejects.toThrow(new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE"));
|
||||
});
|
||||
|
||||
test("throws DatabaseError on generic Prisma error", async () => {
|
||||
@@ -526,6 +563,48 @@ describe("updateConnectorWithMappings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("throws CONNECTOR_NAME_DUPLICATE on Connector name unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "name"] },
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "Dup" })).rejects.toThrow(
|
||||
new InvalidInputError("CONNECTOR_NAME_DUPLICATE")
|
||||
);
|
||||
});
|
||||
|
||||
test("throws CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE on formbricks mapping unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "connectorId", "surveyId", "elementId"] },
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
|
||||
new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE")
|
||||
);
|
||||
});
|
||||
|
||||
test("throws CONNECTOR_FIELD_MAPPING_DUPLICATE on field mapping unique violation", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
meta: { target: ["workspaceId", "connectorId", "sourceFieldId", "targetFieldId"] },
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
|
||||
new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE")
|
||||
);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on generic Prisma error", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
|
||||
@@ -212,6 +212,18 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
|
||||
|
||||
// -- Composite functions --
|
||||
|
||||
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const target = error.meta?.target;
|
||||
const targetFields = Array.isArray(target) ? (target as string[]) : [];
|
||||
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
|
||||
return new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE");
|
||||
}
|
||||
if (targetFields.includes("sourceFieldId") || targetFields.includes("targetFieldId")) {
|
||||
return new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE");
|
||||
}
|
||||
return new InvalidInputError("CONNECTOR_NAME_DUPLICATE");
|
||||
};
|
||||
|
||||
export type TFormbricksMappingsInput = {
|
||||
type: "formbricks_survey";
|
||||
mappings: TConnectorFormbricksMappingCreateInput[];
|
||||
@@ -284,7 +296,7 @@ export const createConnectorWithMappings = async (
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError(`Connector with name ${data.name} already exists`);
|
||||
throw mapUniqueConstraintError(error);
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
@@ -359,6 +371,9 @@ export const updateConnectorWithMappings = async (
|
||||
return mapConnectorWithMappings(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw mapUniqueConstraintError(error);
|
||||
}
|
||||
if (error.code === PrismaErrorType.RecordDoesNotExist) {
|
||||
throw new ResourceNotFoundError("Connector", connectorId);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -216,6 +216,7 @@ const baseSurveyProperties = {
|
||||
},
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
autoSelectLanguage: null,
|
||||
attributeFilters: [],
|
||||
...commonMockProperties,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
mockActionClass,
|
||||
mockId,
|
||||
mockOrganizationOutput,
|
||||
mockSurveyLanguages,
|
||||
mockSurveyOutput,
|
||||
mockSurveyWithLogic,
|
||||
mockTransformedSurveyOutput,
|
||||
@@ -628,6 +629,33 @@ describe("Tests for createSurvey", () => {
|
||||
languages: [],
|
||||
} as TSurveyCreateInput;
|
||||
|
||||
const getMultiLanguageCreateSurveyInput = (): TSurveyCreateInput =>
|
||||
({
|
||||
...mockCreateSurveyInput,
|
||||
welcomeCard: {
|
||||
...mockCreateSurveyInput.welcomeCard,
|
||||
headline: { default: "Welcome", de: "Willkommen" },
|
||||
},
|
||||
questions: mockCreateSurveyInput.questions.map((question) => {
|
||||
if ("choices" in question && Array.isArray(question.choices)) {
|
||||
return {
|
||||
...question,
|
||||
headline: { ...question.headline, de: question.headline.default },
|
||||
choices: question.choices.map((choice) => ({
|
||||
...choice,
|
||||
label: { ...choice.label, de: choice.label.default },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...question,
|
||||
headline: { ...question.headline, de: question.headline.default },
|
||||
};
|
||||
}),
|
||||
languages: mockSurveyLanguages,
|
||||
}) as TSurveyCreateInput;
|
||||
|
||||
const mockActionClasses = [
|
||||
{
|
||||
id: "action-123",
|
||||
@@ -660,6 +688,45 @@ describe("Tests for createSurvey", () => {
|
||||
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("enables browser language auto-selection by default for new multi-language surveys", async () => {
|
||||
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
|
||||
prisma.survey.create.mockResolvedValueOnce({
|
||||
...mockSurveyOutput,
|
||||
});
|
||||
|
||||
await createSurvey(mockWorkspaceId, {
|
||||
...getMultiLanguageCreateSurveyInput(),
|
||||
});
|
||||
|
||||
expect(prisma.survey.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
autoSelectLanguage: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves explicit browser language auto-selection setting on create", async () => {
|
||||
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
|
||||
prisma.survey.create.mockResolvedValueOnce({
|
||||
...mockSurveyOutput,
|
||||
});
|
||||
|
||||
await createSurvey(mockWorkspaceId, {
|
||||
...getMultiLanguageCreateSurveyInput(),
|
||||
autoSelectLanguage: false,
|
||||
});
|
||||
|
||||
expect(prisma.survey.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
autoSelectLanguage: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("creates a private segment for app surveys", async () => {
|
||||
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
|
||||
prisma.survey.create.mockResolvedValueOnce({
|
||||
|
||||
@@ -64,6 +64,7 @@ export const selectSurvey = {
|
||||
singleUse: true,
|
||||
pin: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
recaptcha: true,
|
||||
metadata: true,
|
||||
customHeadScripts: true,
|
||||
@@ -630,6 +631,7 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
|
||||
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
|
||||
const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null;
|
||||
const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null;
|
||||
const hasMultipleEnabledLanguages = (languages ?? []).filter((language) => language.enabled).length > 1;
|
||||
|
||||
const actionClasses = await getActionClasses(parsedWorkspaceId);
|
||||
|
||||
@@ -640,6 +642,8 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
|
||||
publishOn: normalizedPublishOn,
|
||||
status: restSurveyBody.status ?? "draft",
|
||||
}),
|
||||
autoSelectLanguage:
|
||||
restSurveyBody.autoSelectLanguage ?? (hasMultipleEnabledLanguages ? true : undefined),
|
||||
// @ts-expect-error - languages would be undefined in case of empty array
|
||||
languages: languages?.length ? languages : undefined,
|
||||
triggers: restSurveyBody.triggers
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as nextHeaders from "next/headers";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { findMatchingLocale } from "./locale";
|
||||
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "./locale";
|
||||
|
||||
// Mock the Next.js headers function
|
||||
vi.mock("next/headers", () => ({
|
||||
@@ -36,6 +36,26 @@ describe("locale", () => {
|
||||
expect(nextHeaders.headers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ignores Accept-Language quality values when matching locales", async () => {
|
||||
vi.mocked(nextHeaders.headers).mockReturnValue({
|
||||
get: vi.fn().mockReturnValue("de-DE;q=0.9,en-US;q=0.8"),
|
||||
} as any);
|
||||
|
||||
const result = await findMatchingLocale();
|
||||
|
||||
expect(result).toBe("de-DE");
|
||||
});
|
||||
|
||||
test("returns browser language codes without quality values", async () => {
|
||||
vi.mocked(nextHeaders.headers).mockReturnValue({
|
||||
get: vi.fn().mockReturnValue("es-MX,es;q=0.9,en-US;q=0.8"),
|
||||
} as any);
|
||||
|
||||
const result = await findMatchingBrowserLanguageCodes();
|
||||
|
||||
expect(result).toEqual(["es-MX", "es", "en-US"]);
|
||||
});
|
||||
|
||||
test("returns normalized match when available", async () => {
|
||||
// Assuming we have 'en-US' in AVAILABLE_LOCALES but not 'en-GB'
|
||||
const availableLocale = AVAILABLE_LOCALES.find((locale) => locale.startsWith("en-"));
|
||||
|
||||
@@ -2,11 +2,25 @@ import { headers } from "next/headers";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
|
||||
|
||||
const getAcceptedLanguageCodesFromHeader = (acceptLanguage: string | null): string[] => {
|
||||
return (
|
||||
acceptLanguage
|
||||
?.split(",")
|
||||
.map((language) => language.trim().split(";")[0].trim())
|
||||
.filter(Boolean) ?? []
|
||||
);
|
||||
};
|
||||
|
||||
export const findMatchingBrowserLanguageCodes = async (): Promise<string[]> => {
|
||||
const headersList = await headers();
|
||||
return getAcceptedLanguageCodesFromHeader(headersList.get("accept-language"));
|
||||
};
|
||||
|
||||
export const findMatchingLocale = async (): Promise<TUserLocale> => {
|
||||
const headersList = await headers();
|
||||
const acceptLanguage = headersList.get("accept-language");
|
||||
const userLocales = acceptLanguage?.split(",");
|
||||
if (!userLocales) {
|
||||
const userLocales = getAcceptedLanguageCodesFromHeader(acceptLanguage);
|
||||
if (!userLocales.length) {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
// First, try to find an exact match without normalization
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "z.B. Wie viele Nutzer haben sich letzte Woche angemeldet?",
|
||||
"ai_query_section_description": "Beschreibe, was du sehen möchtest, und lass die KI das Diagramm erstellen.",
|
||||
"ai_query_section_title": "Frag deine Daten",
|
||||
"already_on_dashboard": "Bereits im Dashboard",
|
||||
"and_filter_logic": "UND",
|
||||
"apply_changes": "Änderungen übernehmen",
|
||||
"chart": "Diagramm",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Diagramm konnte nicht gespeichert werden",
|
||||
"field": "Feld",
|
||||
"field_label_average_score": "Durchschnittliche Bewertung",
|
||||
"field_label_ces_average": "CES-Durchschnitt",
|
||||
"field_label_ces_count": "CES-Anzahl",
|
||||
"field_label_collected_at": "Erfasst am",
|
||||
"field_label_count": "Anzahl",
|
||||
"field_label_created_at": "Erstellt am",
|
||||
"field_label_csat_average": "CSAT-Durchschnitt",
|
||||
"field_label_csat_count": "CSAT-Anzahl",
|
||||
"field_label_csat_dissatisfied_count": "CSAT Unzufriedene Anzahl",
|
||||
"field_label_csat_neutral_count": "CSAT Neutrale Anzahl",
|
||||
"field_label_csat_satisfied_count": "CSAT-Zufriedene Anzahl",
|
||||
"field_label_csat_score": "CSAT-Score",
|
||||
"field_label_detractor_count": "Anzahl Kritiker",
|
||||
"field_label_emotion": "Emotion",
|
||||
"field_label_field_type": "Feldtyp",
|
||||
"field_label_language": "Sprache",
|
||||
"field_label_nps_average": "NPS-Durchschnitt",
|
||||
"field_label_nps_score": "NPS-Score",
|
||||
"field_label_nps_value": "NPS-Wert",
|
||||
"field_label_passive_count": "Anzahl Passive",
|
||||
"field_label_promoter_count": "Anzahl Promoter",
|
||||
"field_label_question": "Frage",
|
||||
"field_label_question_group": "Fragengruppe",
|
||||
"field_label_response_id": "Antwort-ID",
|
||||
"field_label_sentiment": "Stimmung",
|
||||
"field_label_source_name": "Quellenname",
|
||||
"field_label_source_type": "Quellentyp",
|
||||
"field_label_topic": "Thema",
|
||||
"field_label_unique_respondents": "Eindeutige Teilnehmer",
|
||||
"field_label_unique_responses": "Eindeutige Antworten",
|
||||
"field_label_updated_at": "Aktualisiert am",
|
||||
"field_label_user_identifier": "Benutzerkennung",
|
||||
"field_label_value_boolean": "Wert (Boolean)",
|
||||
"field_label_value_date": "Wert (Datum)",
|
||||
"field_label_value_number": "Wert (Zahl)",
|
||||
"field_label_value_text": "Wert (Text)",
|
||||
"filter_data": "Daten filtern",
|
||||
"filters": "Filter",
|
||||
"filters_toggle_description": "Nur Daten einbeziehen, die die folgenden Bedingungen erfüllen.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} Diagramm(e) hinzufügen",
|
||||
"chart_removed": "Diagramm vom Dashboard entfernt",
|
||||
"charts_add_failed": "Diagramme konnten nicht zum Dashboard hinzugefügt werden",
|
||||
"charts_add_partial_failure": "{count} Diagramm(e) konnten nicht hinzugefügt werden",
|
||||
"charts_added_to_dashboard": "Diagramme zum Dashboard hinzugefügt",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Automatisches Speichern deaktiviert",
|
||||
"auto_save_disabled_tooltip": "Deine Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht versehentlich aktualisiert werden.",
|
||||
"auto_save_on": "Automatisches Speichern an",
|
||||
"auto_select_browser_language": "Browsersprache standardmäßig verwenden",
|
||||
"auto_select_browser_language_description": "Öffnet die Umfrage automatisch in der Browsersprache der befragten Person, wenn diese Sprache aktiv ist. Fällt auf die Standardsprache zurück.",
|
||||
"automatically_close_survey_after": "Umfrage automatisch schließen nach",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl an Antworten.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Nutzer nach einer bestimmten Anzahl an Sekunden nicht antwortet.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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.",
|
||||
"delete_source_confirmation": "Wenn du diese Quelle löschst, werden zukünftige Importe gestoppt und das gespeicherte Mapping entfernt. Vorhandene Feedback-Datensätze bleiben weiterhin verfügbar.",
|
||||
"discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.",
|
||||
"discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?",
|
||||
"drop_a_field_here": "Ziehe ein Feld hierher",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Gib einen Namen für diese Quelle ein",
|
||||
"enter_value": "Wert eingeben...",
|
||||
"enum": "Aufzählung",
|
||||
"error_connector_field_mapping_duplicate": "Doppelte Feldzuordnung für diese Quelle",
|
||||
"error_connector_formbricks_mapping_duplicate": "Doppelte Fragenzuordnung für diese Quelle",
|
||||
"error_connector_name_duplicate": "Eine Quelle mit diesem Namen existiert bereits",
|
||||
"error_connector_name_required": "Quellenname ist erforderlich",
|
||||
"error_connector_questions_required": "Wähle mindestens eine Frage aus",
|
||||
"error_connector_survey_required": "Wähle eine Umfrage aus",
|
||||
"failed_to_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",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Für diesen Workspace muss ein Feedback-Verzeichnis eingerichtet werden, bevor diese Funktion verfügbar ist. Bitte einen Organisationsinhaber oder Manager, eins zuzuweisen.",
|
||||
"no_feedback_directory_linked_title": "Kein Feedback-Verzeichnis verknüpft",
|
||||
"no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.",
|
||||
"no_formbricks_surveys_available_description": "In diesem Workspace gibt es noch keine Umfragen. <surveyLink>Erstelle eine neue Umfrage</surveyLink>, um eine als Feedback-Quelle zu verwenden.",
|
||||
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
|
||||
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
|
||||
"optional": "Optional",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "e.g. How many users signed up last week?",
|
||||
"ai_query_section_description": "Describe what you want to see and let AI build the chart.",
|
||||
"ai_query_section_title": "Ask your data",
|
||||
"already_on_dashboard": "Already on dashboard",
|
||||
"and_filter_logic": "AND",
|
||||
"apply_changes": "Apply Changes",
|
||||
"chart": "Chart",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Failed to save chart",
|
||||
"field": "Field",
|
||||
"field_label_average_score": "Average Score",
|
||||
"field_label_ces_average": "CES Average",
|
||||
"field_label_ces_count": "CES Count",
|
||||
"field_label_collected_at": "Collected At",
|
||||
"field_label_count": "Count",
|
||||
"field_label_created_at": "Created At",
|
||||
"field_label_csat_average": "CSAT Average",
|
||||
"field_label_csat_count": "CSAT Count",
|
||||
"field_label_csat_dissatisfied_count": "CSAT Dissatisfied Count",
|
||||
"field_label_csat_neutral_count": "CSAT Neutral Count",
|
||||
"field_label_csat_satisfied_count": "CSAT Satisfied Count",
|
||||
"field_label_csat_score": "CSAT Score",
|
||||
"field_label_detractor_count": "Detractor Count",
|
||||
"field_label_emotion": "Emotion",
|
||||
"field_label_field_type": "Field Type",
|
||||
"field_label_language": "Language",
|
||||
"field_label_nps_average": "NPS Average",
|
||||
"field_label_nps_score": "NPS Score",
|
||||
"field_label_nps_value": "NPS Value",
|
||||
"field_label_passive_count": "Passive Count",
|
||||
"field_label_promoter_count": "Promoter Count",
|
||||
"field_label_question": "Question",
|
||||
"field_label_question_group": "Question Group",
|
||||
"field_label_response_id": "Response ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Source Name",
|
||||
"field_label_source_type": "Source Type",
|
||||
"field_label_topic": "Topic",
|
||||
"field_label_unique_respondents": "Unique Respondents",
|
||||
"field_label_unique_responses": "Unique Responses",
|
||||
"field_label_updated_at": "Updated At",
|
||||
"field_label_user_identifier": "User Identifier",
|
||||
"field_label_value_boolean": "Value (Boolean)",
|
||||
"field_label_value_date": "Value (Date)",
|
||||
"field_label_value_number": "Value (Number)",
|
||||
"field_label_value_text": "Value (Text)",
|
||||
"filter_data": "Filter data",
|
||||
"filters": "Filters",
|
||||
"filters_toggle_description": "Only include data that meets the following conditions.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Add {count} chart(s)",
|
||||
"chart_removed": "Chart removed from dashboard",
|
||||
"charts_add_failed": "Failed to add charts to dashboard",
|
||||
"charts_add_partial_failure": "Failed to add {count} chart(s)",
|
||||
"charts_added_to_dashboard": "Charts added to dashboard",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Auto-save disabled",
|
||||
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
|
||||
"auto_save_on": "Auto-save on",
|
||||
"auto_select_browser_language": "Use browser language by default",
|
||||
"auto_select_browser_language_description": "Automatically open the survey in the respondent's browser language when that language is active. Falls back to the default language.",
|
||||
"automatically_close_survey_after": "Automatically close survey after",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Automatically close the survey after a certain number of responses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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.",
|
||||
"delete_source_confirmation": "Deleting this source will stop future imports and remove its saved mapping. Existing feedback records will remain available.",
|
||||
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
|
||||
"discard_feedback_record_changes_title": "Discard unsaved changes?",
|
||||
"drop_a_field_here": "Drop a field here",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Enter a name for this source",
|
||||
"enter_value": "Enter value...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Duplicate field mapping for this source",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplicate question mapping for this source",
|
||||
"error_connector_name_duplicate": "A source with this name already exists",
|
||||
"error_connector_name_required": "Source name is required",
|
||||
"error_connector_questions_required": "Select at least one question",
|
||||
"error_connector_survey_required": "Select a survey",
|
||||
"failed_to_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",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "A feedback directory needs to be set up for this workspace before this functionality is available. Ask an organization owner or manager to assign one.",
|
||||
"no_feedback_directory_linked_title": "No feedback directory linked",
|
||||
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
|
||||
"no_formbricks_surveys_available_description": "There are no surveys in this workspace yet. <surveyLink>Create a new survey</surveyLink> to use it as a feedback source.",
|
||||
"no_source_fields_loaded": "No source fields loaded yet",
|
||||
"no_sources_connected": "No sources connected yet. Add a source to get started.",
|
||||
"optional": "Optional",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "p. ej. ¿Cuántos usuarios se registraron la semana pasada?",
|
||||
"ai_query_section_description": "Describe lo que quieres ver y deja que la IA construya el gráfico.",
|
||||
"ai_query_section_title": "Pregunta a tus datos",
|
||||
"already_on_dashboard": "Ya está en el panel",
|
||||
"and_filter_logic": "Y",
|
||||
"apply_changes": "Aplicar cambios",
|
||||
"chart": "Gráfico",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Error al guardar el gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Puntuación media",
|
||||
"field_label_ces_average": "Promedio CES",
|
||||
"field_label_ces_count": "Recuento CES",
|
||||
"field_label_collected_at": "Recopilado el",
|
||||
"field_label_count": "Recuento",
|
||||
"field_label_created_at": "Fecha de creación",
|
||||
"field_label_csat_average": "Promedio CSAT",
|
||||
"field_label_csat_count": "Recuento CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Recuento de insatisfechos CSAT",
|
||||
"field_label_csat_neutral_count": "Recuento de neutros CSAT",
|
||||
"field_label_csat_satisfied_count": "Recuento de Satisfechos CSAT",
|
||||
"field_label_csat_score": "Puntuación CSAT",
|
||||
"field_label_detractor_count": "Recuento de detractores",
|
||||
"field_label_emotion": "Emoción",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_language": "Idioma",
|
||||
"field_label_nps_average": "Promedio NPS",
|
||||
"field_label_nps_score": "Puntuación NPS",
|
||||
"field_label_nps_value": "Valor NPS",
|
||||
"field_label_passive_count": "Recuento de pasivos",
|
||||
"field_label_promoter_count": "Recuento de promotores",
|
||||
"field_label_question": "Pregunta",
|
||||
"field_label_question_group": "Grupo de preguntas",
|
||||
"field_label_response_id": "ID de respuesta",
|
||||
"field_label_sentiment": "Sentimiento",
|
||||
"field_label_source_name": "Nombre de origen",
|
||||
"field_label_source_type": "Tipo de origen",
|
||||
"field_label_topic": "Tema",
|
||||
"field_label_unique_respondents": "Encuestados Únicos",
|
||||
"field_label_unique_responses": "Respuestas Únicas",
|
||||
"field_label_updated_at": "Fecha de actualización",
|
||||
"field_label_user_identifier": "Identificador de usuario",
|
||||
"field_label_value_boolean": "Valor (Booleano)",
|
||||
"field_label_value_date": "Valor (Fecha)",
|
||||
"field_label_value_number": "Valor (Número)",
|
||||
"field_label_value_text": "Valor (Texto)",
|
||||
"filter_data": "Filtrar datos",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluye solo los datos que cumplan las siguientes condiciones.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Añadir {count} gráfico(s)",
|
||||
"chart_removed": "Gráfico eliminado del panel",
|
||||
"charts_add_failed": "Error al añadir gráficos al panel",
|
||||
"charts_add_partial_failure": "Error al añadir {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos añadidos al panel",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Guardado automático desactivado",
|
||||
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
|
||||
"auto_save_on": "Guardado automático activado",
|
||||
"auto_select_browser_language": "Usar el idioma del navegador por defecto",
|
||||
"auto_select_browser_language_description": "Abre automáticamente la encuesta en el idioma del navegador de la persona encuestada cuando ese idioma está activo. Si no coincide, usa el idioma predeterminado.",
|
||||
"automatically_close_survey_after": "Cerrar automáticamente la encuesta después de",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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.",
|
||||
"delete_source_confirmation": "Eliminar esta fuente detendrá futuras importaciones y eliminará su mapeo guardado. Los registros de comentarios existentes seguirán estando disponibles.",
|
||||
"discard_feedback_record_changes_description": "Sus cambios se perderán si cierra este cajón.",
|
||||
"discard_feedback_record_changes_title": "¿Descartar los cambios no guardados?",
|
||||
"drop_a_field_here": "Suelta un campo aquí",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Introduce un nombre para este origen",
|
||||
"enter_value": "Introduce un valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeo de campo duplicado para esta fuente",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeo de pregunta duplicado para esta fuente",
|
||||
"error_connector_name_duplicate": "Ya existe una fuente con este nombre",
|
||||
"error_connector_name_required": "El nombre de origen es obligatorio",
|
||||
"error_connector_questions_required": "Selecciona al menos una pregunta",
|
||||
"error_connector_survey_required": "Selecciona una encuesta",
|
||||
"failed_to_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",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Es necesario configurar un directorio de feedback para este espacio de trabajo antes de que esta funcionalidad esté disponible. Solicita a un propietario o gestor de la organización que asigne uno.",
|
||||
"no_feedback_directory_linked_title": "No hay ningún directorio de feedback vinculado",
|
||||
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
|
||||
"no_formbricks_surveys_available_description": "Todavía no hay encuestas en este espacio de trabajo. <surveyLink>Crea una nueva encuesta</surveyLink> para usar una como fuente de feedback.",
|
||||
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
|
||||
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
|
||||
"optional": "Opcional",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "ex. Combien d'utilisateurs se sont inscrits la semaine dernière ?",
|
||||
"ai_query_section_description": "Décrivez ce que vous souhaitez voir et laissez l'IA créer le graphique.",
|
||||
"ai_query_section_title": "Interrogez vos données",
|
||||
"already_on_dashboard": "Déjà sur le tableau de bord",
|
||||
"and_filter_logic": "ET",
|
||||
"apply_changes": "Appliquer les modifications",
|
||||
"chart": "Graphique",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Échec de l'enregistrement du graphique",
|
||||
"field": "Champ",
|
||||
"field_label_average_score": "Score moyen",
|
||||
"field_label_ces_average": "Moyenne CES",
|
||||
"field_label_ces_count": "Nombre CES",
|
||||
"field_label_collected_at": "Collecté le",
|
||||
"field_label_count": "Nombre",
|
||||
"field_label_created_at": "Créé le",
|
||||
"field_label_csat_average": "Moyenne CSAT",
|
||||
"field_label_csat_count": "Nombre CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Nombre d'insatisfaits CSAT",
|
||||
"field_label_csat_neutral_count": "Nombre de neutres CSAT",
|
||||
"field_label_csat_satisfied_count": "Nombre de clients satisfaits CSAT",
|
||||
"field_label_csat_score": "Score CSAT",
|
||||
"field_label_detractor_count": "Nombre de détracteurs",
|
||||
"field_label_emotion": "Émotion",
|
||||
"field_label_field_type": "Type de champ",
|
||||
"field_label_language": "Langue",
|
||||
"field_label_nps_average": "Moyenne NPS",
|
||||
"field_label_nps_score": "Score NPS",
|
||||
"field_label_nps_value": "Valeur NPS",
|
||||
"field_label_passive_count": "Nombre de passifs",
|
||||
"field_label_promoter_count": "Nombre de promoteurs",
|
||||
"field_label_question": "Question",
|
||||
"field_label_question_group": "Groupe de questions",
|
||||
"field_label_response_id": "ID de réponse",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Nom de la source",
|
||||
"field_label_source_type": "Type de source",
|
||||
"field_label_topic": "Sujet",
|
||||
"field_label_unique_respondents": "Répondants uniques",
|
||||
"field_label_unique_responses": "Réponses uniques",
|
||||
"field_label_updated_at": "Mis à jour le",
|
||||
"field_label_user_identifier": "Identifiant utilisateur",
|
||||
"field_label_value_boolean": "Valeur (booléenne)",
|
||||
"field_label_value_date": "Valeur (date)",
|
||||
"field_label_value_number": "Valeur (Nombre)",
|
||||
"field_label_value_text": "Valeur (Texte)",
|
||||
"filter_data": "Filtrer les données",
|
||||
"filters": "Filtres",
|
||||
"filters_toggle_description": "Inclure uniquement les données qui répondent aux conditions suivantes.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Ajouter {count} graphique(s)",
|
||||
"chart_removed": "Graphique retiré du tableau de bord",
|
||||
"charts_add_failed": "Échec de l'ajout des graphiques au tableau de bord",
|
||||
"charts_add_partial_failure": "Échec de l'ajout de {count} graphique(s)",
|
||||
"charts_added_to_dashboard": "Graphiques ajoutés au tableau de bord",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Sauvegarde automatique désactivée",
|
||||
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
|
||||
"auto_save_on": "Sauvegarde automatique activée",
|
||||
"auto_select_browser_language": "Utiliser la langue du navigateur par défaut",
|
||||
"auto_select_browser_language_description": "Ouvre automatiquement l'enquête dans la langue du navigateur de la personne interrogée lorsque cette langue est active. Sinon, la langue par défaut est utilisée.",
|
||||
"automatically_close_survey_after": "Fermer automatiquement l'enquête après",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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é.",
|
||||
"delete_source_confirmation": "La suppression de cette source arrêtera les futures importations et supprimera sa configuration de mapping enregistrée. Les enregistrements de feedback existants resteront disponibles.",
|
||||
"discard_feedback_record_changes_description": "Vos modifications seront perdues si vous fermez ce tiroir.",
|
||||
"discard_feedback_record_changes_title": "Supprimer les modifications non enregistrées ?",
|
||||
"drop_a_field_here": "Déposez un champ ici",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Entrez un nom pour cette source",
|
||||
"enter_value": "Saisir une valeur...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mappage de champ en double pour cette source",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mappage de question en double pour cette source",
|
||||
"error_connector_name_duplicate": "Une source avec ce nom existe déjà",
|
||||
"error_connector_name_required": "Le nom de la source est requis",
|
||||
"error_connector_questions_required": "Sélectionnez au moins une question",
|
||||
"error_connector_survey_required": "Sélectionnez une enquête",
|
||||
"failed_to_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",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Un répertoire de feedback doit être configuré pour cet espace de travail avant que cette fonctionnalité ne soit disponible. Demande à un propriétaire ou un gestionnaire de l'organisation d'en attribuer un.",
|
||||
"no_feedback_directory_linked_title": "Aucun répertoire de feedback lié",
|
||||
"no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.",
|
||||
"no_formbricks_surveys_available_description": "Il n’y a pas encore de sondages dans cet espace de travail. <surveyLink>Créez une nouvelle enquête</surveyLink> pour en utiliser une comme source de feedback.",
|
||||
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
|
||||
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
|
||||
"optional": "Facultatif",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "pl. Hány felhasználó regisztrált a múlt héten?",
|
||||
"ai_query_section_description": "Írd le, mit szeretnél látni, és hagyd, hogy az AI elkészítse a diagramot.",
|
||||
"ai_query_section_title": "Kérdezd meg az adataidat",
|
||||
"already_on_dashboard": "Már a vezérlőpulton van",
|
||||
"and_filter_logic": "ÉS",
|
||||
"apply_changes": "Módosítások alkalmazása",
|
||||
"chart": "Diagram",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "A diagram mentése sikertelen",
|
||||
"field": "Mező",
|
||||
"field_label_average_score": "Átlagos pontszám",
|
||||
"field_label_ces_average": "CES átlag",
|
||||
"field_label_ces_count": "CES darabszám",
|
||||
"field_label_collected_at": "Gyűjtve",
|
||||
"field_label_count": "Darabszám",
|
||||
"field_label_created_at": "Létrehozás dátuma",
|
||||
"field_label_csat_average": "CSAT átlag",
|
||||
"field_label_csat_count": "CSAT darabszám",
|
||||
"field_label_csat_dissatisfied_count": "CSAT elégedetlen válaszok száma",
|
||||
"field_label_csat_neutral_count": "CSAT semleges válaszok száma",
|
||||
"field_label_csat_satisfied_count": "CSAT elégedett válaszadók száma",
|
||||
"field_label_csat_score": "CSAT pontszám",
|
||||
"field_label_detractor_count": "Kritikusok száma",
|
||||
"field_label_emotion": "Érzelem",
|
||||
"field_label_field_type": "Mező típusa",
|
||||
"field_label_language": "Nyelv",
|
||||
"field_label_nps_average": "NPS átlag",
|
||||
"field_label_nps_score": "NPS pontszám",
|
||||
"field_label_nps_value": "NPS érték",
|
||||
"field_label_passive_count": "Passzívak száma",
|
||||
"field_label_promoter_count": "Támogatók száma",
|
||||
"field_label_question": "Kérdés",
|
||||
"field_label_question_group": "Kérdéscsoport",
|
||||
"field_label_response_id": "Válaszazonosító",
|
||||
"field_label_sentiment": "Hangulat",
|
||||
"field_label_source_name": "Forrás neve",
|
||||
"field_label_source_type": "Forrás típusa",
|
||||
"field_label_topic": "Téma",
|
||||
"field_label_unique_respondents": "Egyedi válaszadók",
|
||||
"field_label_unique_responses": "Egyedi válaszok",
|
||||
"field_label_updated_at": "Frissítés dátuma",
|
||||
"field_label_user_identifier": "Felhasználóazonosító",
|
||||
"field_label_value_boolean": "Érték (logikai)",
|
||||
"field_label_value_date": "Érték (dátum)",
|
||||
"field_label_value_number": "Érték (szám)",
|
||||
"field_label_value_text": "Érték (szöveg)",
|
||||
"filter_data": "Adatok szűrése",
|
||||
"filters": "Szűrők",
|
||||
"filters_toggle_description": "Csak azokat az adatokat tartalmazza, amelyek megfelelnek a következő feltételeknek.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} diagram hozzáadása",
|
||||
"chart_removed": "A diagram eltávolítva a műszerfalról",
|
||||
"charts_add_failed": "A diagramok műszerfalhoz való hozzáadása sikertelen",
|
||||
"charts_add_partial_failure": "{count} diagram hozzáadása sikertelen",
|
||||
"charts_added_to_dashboard": "Diagramok hozzáadva a műszerfalhoz",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Az automatikus mentés letiltva",
|
||||
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
|
||||
"auto_save_on": "Automatikus mentés bekapcsolva",
|
||||
"auto_select_browser_language": "A böngésző nyelvének használata alapértelmezésként",
|
||||
"auto_select_browser_language_description": "Automatikusan a válaszadó böngészőnyelvén nyitja meg a kérdőívet, ha ez a nyelv aktív. Ellenkező esetben az alapértelmezett nyelvre vált.",
|
||||
"automatically_close_survey_after": "Kérdőív automatikus lezárása ezután:",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "A kérdőív automatikus lezárása egy bizonyos számú válasz után.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "A kérdőív automatikus lezárása, ha a felhasználó nem válaszol egy bizonyos másodpercnyi idő után.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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.",
|
||||
"delete_source_confirmation": "Ezen forrás törlésével leállítja a jövőbeli importálásokat, és eltávolítja annak mentett leképezését. A meglévő visszajelzési rekordok továbbra is elérhetők maradnak.",
|
||||
"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",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Adj nevet ennek a forrásnak",
|
||||
"enter_value": "Érték megadása...",
|
||||
"enum": "felsorolás",
|
||||
"error_connector_field_mapping_duplicate": "Duplikált mezőleképezés ehhez a forráshoz",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplikált kérdésleképezés ehhez a forráshoz",
|
||||
"error_connector_name_duplicate": "Már létezik forrás ezzel a névvel",
|
||||
"error_connector_name_required": "A forrás neve kötelező",
|
||||
"error_connector_questions_required": "Válasszon ki legalább egy kérdést",
|
||||
"error_connector_survey_required": "Válasszon ki egy felmérést",
|
||||
"failed_to_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",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Ehhez a munkaterülethez be kell állítani egy visszajelzési könyvtárat, mielőtt ez a funkció elérhetővé válna. Kérje meg a szervezet tulajdonosát vagy vezetőjét, hogy rendeljen hozzá egyet.",
|
||||
"no_feedback_directory_linked_title": "Nincs visszajelzési könyvtár kapcsolva",
|
||||
"no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.",
|
||||
"no_formbricks_surveys_available_description": "Ebben a munkaterületen még nincsenek kérdőívek. <surveyLink>Hozz létre egy új kérdőívet</surveyLink>, hogy visszajelzési forrásként használhass egyet.",
|
||||
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
|
||||
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
|
||||
"optional": "Elhagyható",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "例: 先週何人のユーザーが登録しましたか?",
|
||||
"ai_query_section_description": "表示したい内容を説明すると、AIがチャートを作成します。",
|
||||
"ai_query_section_title": "データに質問する",
|
||||
"already_on_dashboard": "すでにダッシュボードに追加済み",
|
||||
"and_filter_logic": "AND",
|
||||
"apply_changes": "変更を適用",
|
||||
"chart": "チャート",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "チャートの保存に失敗しました",
|
||||
"field": "フィールド",
|
||||
"field_label_average_score": "平均スコア",
|
||||
"field_label_ces_average": "CES平均",
|
||||
"field_label_ces_count": "CES件数",
|
||||
"field_label_collected_at": "収集日時",
|
||||
"field_label_count": "カウント",
|
||||
"field_label_created_at": "作成日時",
|
||||
"field_label_csat_average": "CSAT平均",
|
||||
"field_label_csat_count": "CSAT件数",
|
||||
"field_label_csat_dissatisfied_count": "CSAT 不満足数",
|
||||
"field_label_csat_neutral_count": "CSAT 中立数",
|
||||
"field_label_csat_satisfied_count": "CSAT満足件数",
|
||||
"field_label_csat_score": "CSATスコア",
|
||||
"field_label_detractor_count": "批判者数",
|
||||
"field_label_emotion": "感情",
|
||||
"field_label_field_type": "フィールドタイプ",
|
||||
"field_label_language": "言語",
|
||||
"field_label_nps_average": "NPS平均",
|
||||
"field_label_nps_score": "NPSスコア",
|
||||
"field_label_nps_value": "NPS値",
|
||||
"field_label_passive_count": "中立者数",
|
||||
"field_label_promoter_count": "推奨者数",
|
||||
"field_label_question": "質問",
|
||||
"field_label_question_group": "質問グループ",
|
||||
"field_label_response_id": "回答ID",
|
||||
"field_label_sentiment": "感情分析",
|
||||
"field_label_source_name": "ソース名",
|
||||
"field_label_source_type": "ソースタイプ",
|
||||
"field_label_topic": "トピック",
|
||||
"field_label_unique_respondents": "ユニーク回答者数",
|
||||
"field_label_unique_responses": "ユニーク回答数",
|
||||
"field_label_updated_at": "更新日時",
|
||||
"field_label_user_identifier": "ユーザー識別子",
|
||||
"field_label_value_boolean": "値(ブール値)",
|
||||
"field_label_value_date": "値(日付)",
|
||||
"field_label_value_number": "値(数値)",
|
||||
"field_label_value_text": "値(テキスト)",
|
||||
"filter_data": "データをフィルター",
|
||||
"filters": "フィルター",
|
||||
"filters_toggle_description": "以下の条件を満たすデータのみを含めます。",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count}個のグラフを追加",
|
||||
"chart_removed": "チャートがダッシュボードから削除されました",
|
||||
"charts_add_failed": "ダッシュボードへのグラフの追加に失敗しました",
|
||||
"charts_add_partial_failure": "{count}個のグラフの追加に失敗しました",
|
||||
"charts_added_to_dashboard": "グラフをダッシュボードに追加しました",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "自動保存が無効",
|
||||
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
|
||||
"auto_save_on": "自動保存オン",
|
||||
"auto_select_browser_language": "ブラウザーの言語をデフォルトで使用",
|
||||
"auto_select_browser_language_description": "その言語が有効な場合、回答者のブラウザー言語でアンケートを自動的に開きます。一致しない場合はデフォルト言語に戻ります。",
|
||||
"automatically_close_survey_after": "フォームを自動的に閉じる",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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}件のフィードバック記録が完全に削除され、接続されているディレクトリから削除されます。",
|
||||
"delete_source_confirmation": "このソースを削除すると、今後のインポートが停止され、保存されたマッピングが削除されます。既存のフィードバック記録は引き続き利用可能です。",
|
||||
"discard_feedback_record_changes_description": "このドロワーを閉じると、変更内容は失われます。",
|
||||
"discard_feedback_record_changes_title": "保存されていない変更を破棄しますか?",
|
||||
"drop_a_field_here": "ここにフィールドをドロップ",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "このソースの名前を入力",
|
||||
"enter_value": "値を入力...",
|
||||
"enum": "列挙型",
|
||||
"error_connector_field_mapping_duplicate": "このソースのフィールドマッピングが重複しています",
|
||||
"error_connector_formbricks_mapping_duplicate": "このソースの質問マッピングが重複しています",
|
||||
"error_connector_name_duplicate": "この名前のソースは既に存在します",
|
||||
"error_connector_name_required": "ソース名は必須です",
|
||||
"error_connector_questions_required": "少なくとも1つの質問を選択してください",
|
||||
"error_connector_survey_required": "アンケートを選択してください",
|
||||
"failed_to_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": "フィードバックレコードフィールド",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "この機能を利用するには、このワークスペースにフィードバックディレクトリを設定する必要があります。組織のオーナーまたはマネージャーに割り当てを依頼してください。",
|
||||
"no_feedback_directory_linked_title": "フィードバックディレクトリが未リンク",
|
||||
"no_feedback_records": "フィードバックレコードはまだありません。コネクタがデータの送信を開始すると、ここにレコードが表示されます。",
|
||||
"no_formbricks_surveys_available_description": "このワークスペースにはまだフォームがありません。フィードバックソースとして使用するには<surveyLink>新しいフォームを作成</surveyLink>してください。",
|
||||
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
|
||||
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
|
||||
"optional": "任意",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "bijv. Hoeveel gebruikers hebben zich vorige week aangemeld?",
|
||||
"ai_query_section_description": "Beschrijf wat je wilt zien en laat AI de grafiek bouwen.",
|
||||
"ai_query_section_title": "Vraag het aan je data",
|
||||
"already_on_dashboard": "Al op dashboard",
|
||||
"and_filter_logic": "EN",
|
||||
"apply_changes": "Wijzigingen toepassen",
|
||||
"chart": "Grafiek",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Opslaan van diagram mislukt",
|
||||
"field": "Veld",
|
||||
"field_label_average_score": "Gemiddelde score",
|
||||
"field_label_ces_average": "CES Gemiddelde",
|
||||
"field_label_ces_count": "CES Aantal",
|
||||
"field_label_collected_at": "Verzameld op",
|
||||
"field_label_count": "Aantal",
|
||||
"field_label_created_at": "Aangemaakt op",
|
||||
"field_label_csat_average": "CSAT Gemiddelde",
|
||||
"field_label_csat_count": "CSAT Aantal",
|
||||
"field_label_csat_dissatisfied_count": "CSAT Aantal ontevreden",
|
||||
"field_label_csat_neutral_count": "CSAT Aantal neutraal",
|
||||
"field_label_csat_satisfied_count": "CSAT Tevreden Aantal",
|
||||
"field_label_csat_score": "CSAT Score",
|
||||
"field_label_detractor_count": "Aantal detractors",
|
||||
"field_label_emotion": "Emotie",
|
||||
"field_label_field_type": "Veldtype",
|
||||
"field_label_language": "Taal",
|
||||
"field_label_nps_average": "NPS Gemiddelde",
|
||||
"field_label_nps_score": "NPS-score",
|
||||
"field_label_nps_value": "NPS-waarde",
|
||||
"field_label_passive_count": "Aantal passieven",
|
||||
"field_label_promoter_count": "Aantal promoters",
|
||||
"field_label_question": "Vraag",
|
||||
"field_label_question_group": "Vraaggroep",
|
||||
"field_label_response_id": "Antwoord-ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Bronnaam",
|
||||
"field_label_source_type": "Brontype",
|
||||
"field_label_topic": "Onderwerp",
|
||||
"field_label_unique_respondents": "Unieke Respondenten",
|
||||
"field_label_unique_responses": "Unieke Antwoorden",
|
||||
"field_label_updated_at": "Bijgewerkt op",
|
||||
"field_label_user_identifier": "Gebruikersidentificatie",
|
||||
"field_label_value_boolean": "Waarde (Boolean)",
|
||||
"field_label_value_date": "Waarde (Datum)",
|
||||
"field_label_value_number": "Waarde (Getal)",
|
||||
"field_label_value_text": "Waarde (Tekst)",
|
||||
"filter_data": "Data filteren",
|
||||
"filters": "Filters",
|
||||
"filters_toggle_description": "Neem alleen gegevens op die aan de volgende voorwaarden voldoen.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} grafiek(en) toevoegen",
|
||||
"chart_removed": "Grafiek verwijderd van dashboard",
|
||||
"charts_add_failed": "Grafieken toevoegen aan dashboard mislukt",
|
||||
"charts_add_partial_failure": "{count} grafiek(en) toevoegen mislukt",
|
||||
"charts_added_to_dashboard": "Grafieken toegevoegd aan dashboard",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
|
||||
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
|
||||
"auto_save_on": "Automatisch opslaan aan",
|
||||
"auto_select_browser_language": "Browsertaal standaard gebruiken",
|
||||
"auto_select_browser_language_description": "Opent de enquête automatisch in de browsertaal van de respondent wanneer die taal actief is. Valt terug op de standaardtaal.",
|
||||
"automatically_close_survey_after": "Sluit de enquête daarna automatisch af",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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.",
|
||||
"delete_source_confirmation": "Het verwijderen van deze bron stopt toekomstige imports en verwijdert de opgeslagen mapping. Bestaande feedbackgegevens blijven beschikbaar.",
|
||||
"discard_feedback_record_changes_description": "Als u deze lade sluit, gaan uw wijzigingen verloren.",
|
||||
"discard_feedback_record_changes_title": "Niet-opgeslagen wijzigingen verwijderen?",
|
||||
"drop_a_field_here": "Zet hier een veld neer",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Voer een naam in voor deze bron",
|
||||
"enter_value": "Voer waarde in...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Dubbele veldkoppeling voor deze bron",
|
||||
"error_connector_formbricks_mapping_duplicate": "Dubbele vraagkoppeling voor deze bron",
|
||||
"error_connector_name_duplicate": "Er bestaat al een bron met deze naam",
|
||||
"error_connector_name_required": "Bronnaam is verplicht",
|
||||
"error_connector_questions_required": "Selecteer minimaal één vraag",
|
||||
"error_connector_survey_required": "Selecteer een enquête",
|
||||
"failed_to_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",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Er moet eerst een feedbackmap worden ingesteld voor deze werkruimte voordat deze functionaliteit beschikbaar is. Vraag een organisatie-eigenaar of manager om er een toe te wijzen.",
|
||||
"no_feedback_directory_linked_title": "Geen feedbackmap gekoppeld",
|
||||
"no_feedback_records": "Nog geen feedbackrecords. Records verschijnen hier zodra je connectoren gegevens beginnen te verzenden.",
|
||||
"no_formbricks_surveys_available_description": "Er zijn nog geen enquêtes in deze werkruimte. <surveyLink>Maak een nieuwe enquête</surveyLink> om er een als feedbackbron te gebruiken.",
|
||||
"no_source_fields_loaded": "Nog geen bronvelden geladen",
|
||||
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
|
||||
"optional": "Optioneel",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "ex: Quantos usuários se cadastraram na semana passada?",
|
||||
"ai_query_section_description": "Descreva o que você quer ver e deixe a IA construir o gráfico.",
|
||||
"ai_query_section_title": "Pergunte aos seus dados",
|
||||
"already_on_dashboard": "Já está no painel",
|
||||
"and_filter_logic": "E",
|
||||
"apply_changes": "Aplicar alterações",
|
||||
"chart": "Gráfico",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Falha ao salvar gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Pontuação média",
|
||||
"field_label_ces_average": "Média CES",
|
||||
"field_label_ces_count": "Contagem CES",
|
||||
"field_label_collected_at": "Coletado em",
|
||||
"field_label_count": "Contagem",
|
||||
"field_label_created_at": "Criado em",
|
||||
"field_label_csat_average": "Média CSAT",
|
||||
"field_label_csat_count": "Contagem CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Contagem de Insatisfeitos CSAT",
|
||||
"field_label_csat_neutral_count": "Contagem de Neutros CSAT",
|
||||
"field_label_csat_satisfied_count": "Contagem de Satisfeitos CSAT",
|
||||
"field_label_csat_score": "Pontuação CSAT",
|
||||
"field_label_detractor_count": "Contagem de detratores",
|
||||
"field_label_emotion": "Emoção",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_language": "Idioma",
|
||||
"field_label_nps_average": "Média NPS",
|
||||
"field_label_nps_score": "Pontuação de NPS",
|
||||
"field_label_nps_value": "Valor de NPS",
|
||||
"field_label_passive_count": "Contagem de passivos",
|
||||
"field_label_promoter_count": "Contagem de promotores",
|
||||
"field_label_question": "Pergunta",
|
||||
"field_label_question_group": "Grupo de Perguntas",
|
||||
"field_label_response_id": "ID da resposta",
|
||||
"field_label_sentiment": "Sentimento",
|
||||
"field_label_source_name": "Nome da fonte",
|
||||
"field_label_source_type": "Tipo de fonte",
|
||||
"field_label_topic": "Tópico",
|
||||
"field_label_unique_respondents": "Respondentes Únicos",
|
||||
"field_label_unique_responses": "Respostas Únicas",
|
||||
"field_label_updated_at": "Atualizado em",
|
||||
"field_label_user_identifier": "Identificador do usuário",
|
||||
"field_label_value_boolean": "Valor (Booleano)",
|
||||
"field_label_value_date": "Valor (Data)",
|
||||
"field_label_value_number": "Valor (Número)",
|
||||
"field_label_value_text": "Valor (Texto)",
|
||||
"filter_data": "Filtrar dados",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluir apenas dados que atendam às seguintes condições.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
"chart_removed": "Gráfico removido do painel",
|
||||
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
|
||||
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Salvamento automático desativado",
|
||||
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
|
||||
"auto_save_on": "Salvamento automático ativado",
|
||||
"auto_select_browser_language": "Usar o idioma do navegador por padrão",
|
||||
"auto_select_browser_language_description": "Abre automaticamente a pesquisa no idioma do navegador do respondente quando esse idioma está ativo. Caso contrário, usa o idioma padrão.",
|
||||
"automatically_close_survey_after": "Fechar pesquisa automaticamente após",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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.",
|
||||
"delete_source_confirmation": "Excluir esta fonte interromperá futuras importações e removerá seu mapeamento salvo. Os registros de feedback existentes permanecerão disponíveis.",
|
||||
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
|
||||
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Digite um nome para esta origem",
|
||||
"enter_value": "Digite o valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
|
||||
"error_connector_name_duplicate": "Uma fonte com este nome já existe",
|
||||
"error_connector_name_required": "O nome da fonte é obrigatório",
|
||||
"error_connector_questions_required": "Selecione pelo menos uma pergunta",
|
||||
"error_connector_survey_required": "Selecione uma pesquisa",
|
||||
"failed_to_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",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Um diretório de feedback precisa ser configurado para este workspace antes que esta funcionalidade esteja disponível. Peça a um proprietário ou gerente da organização para configurar um.",
|
||||
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
|
||||
"no_feedback_records": "Nenhum registro de feedback ainda. Os registros aparecerão aqui assim que seus conectores começarem a enviar dados.",
|
||||
"no_formbricks_surveys_available_description": "Ainda não há pesquisas neste workspace. <surveyLink>Crie uma nova pesquisa</surveyLink> para usar uma como fonte de feedback.",
|
||||
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
|
||||
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
|
||||
"optional": "Opcional",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "ex: Quantos utilizadores se registaram na semana passada?",
|
||||
"ai_query_section_description": "Descreve o que queres ver e deixa a IA construir o gráfico.",
|
||||
"ai_query_section_title": "Pergunta aos teus dados",
|
||||
"already_on_dashboard": "Já está no painel",
|
||||
"and_filter_logic": "E",
|
||||
"apply_changes": "Aplicar alterações",
|
||||
"chart": "Gráfico",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Falha ao guardar gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Pontuação média",
|
||||
"field_label_ces_average": "Média CES",
|
||||
"field_label_ces_count": "Contagem CES",
|
||||
"field_label_collected_at": "Recolhido em",
|
||||
"field_label_count": "Contagem",
|
||||
"field_label_created_at": "Criado em",
|
||||
"field_label_csat_average": "Média CSAT",
|
||||
"field_label_csat_count": "Contagem CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Contagem de CSAT Insatisfeito",
|
||||
"field_label_csat_neutral_count": "Contagem de CSAT Neutro",
|
||||
"field_label_csat_satisfied_count": "Contagem de Satisfeitos CSAT",
|
||||
"field_label_csat_score": "Pontuação CSAT",
|
||||
"field_label_detractor_count": "Contagem de detratores",
|
||||
"field_label_emotion": "Emoção",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_language": "Idioma",
|
||||
"field_label_nps_average": "Média NPS",
|
||||
"field_label_nps_score": "Pontuação NPS",
|
||||
"field_label_nps_value": "Valor NPS",
|
||||
"field_label_passive_count": "Contagem de passivos",
|
||||
"field_label_promoter_count": "Contagem de promotores",
|
||||
"field_label_question": "Pergunta",
|
||||
"field_label_question_group": "Grupo de Perguntas",
|
||||
"field_label_response_id": "ID de resposta",
|
||||
"field_label_sentiment": "Sentimento",
|
||||
"field_label_source_name": "Nome da origem",
|
||||
"field_label_source_type": "Tipo de origem",
|
||||
"field_label_topic": "Tópico",
|
||||
"field_label_unique_respondents": "Inquiridos Únicos",
|
||||
"field_label_unique_responses": "Respostas Únicas",
|
||||
"field_label_updated_at": "Atualizado em",
|
||||
"field_label_user_identifier": "Identificador de utilizador",
|
||||
"field_label_value_boolean": "Valor (Booleano)",
|
||||
"field_label_value_date": "Valor (Data)",
|
||||
"field_label_value_number": "Valor (Número)",
|
||||
"field_label_value_text": "Valor (Texto)",
|
||||
"filter_data": "Filtrar dados",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluir apenas dados que cumpram as seguintes condições.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
"chart_removed": "Gráfico removido do painel",
|
||||
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
|
||||
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Guardar automático desativado",
|
||||
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
|
||||
"auto_save_on": "Guardar automático ativado",
|
||||
"auto_select_browser_language": "Usar o idioma do navegador por predefinição",
|
||||
"auto_select_browser_language_description": "Abre automaticamente o inquérito no idioma do navegador do respondente quando esse idioma está ativo. Caso contrário, usa o idioma predefinido.",
|
||||
"automatically_close_survey_after": "Fechar automaticamente o inquérito após",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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.",
|
||||
"delete_source_confirmation": "Eliminar esta origem irá parar importações futuras e remover o seu mapeamento guardado. Os registos de feedback existentes permanecerão disponíveis.",
|
||||
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
|
||||
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Introduz um nome para esta origem",
|
||||
"enter_value": "Introduzir valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
|
||||
"error_connector_name_duplicate": "Já existe uma origem com este nome",
|
||||
"error_connector_name_required": "O nome da origem é obrigatório",
|
||||
"error_connector_questions_required": "Seleciona pelo menos uma pergunta",
|
||||
"error_connector_survey_required": "Seleciona um inquérito",
|
||||
"failed_to_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",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "É necessário configurar um diretório de feedback para este workspace antes de esta funcionalidade estar disponível. Pede a um proprietário ou gestor da organização para atribuir um.",
|
||||
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
|
||||
"no_feedback_records": "Ainda não há registos de feedback. Os registos aparecerão aqui assim que os teus conectores começarem a enviar dados.",
|
||||
"no_formbricks_surveys_available_description": "Ainda não há inquéritos neste workspace. <surveyLink>Cria um novo inquérito</surveyLink> para usar um como fonte de feedback.",
|
||||
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
|
||||
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
|
||||
"optional": "Opcional",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "ex: Câți utilizatori s-au înscris săptămâna trecută?",
|
||||
"ai_query_section_description": "Descrie ce vrei să vezi și lasă AI-ul să construiască graficul.",
|
||||
"ai_query_section_title": "Întreabă-ți datele",
|
||||
"already_on_dashboard": "Deja pe tabloul de bord",
|
||||
"and_filter_logic": "ȘI",
|
||||
"apply_changes": "Aplică modificările",
|
||||
"chart": "Grafic",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Nu s-a putut salva graficul",
|
||||
"field": "Câmp",
|
||||
"field_label_average_score": "Scor mediu",
|
||||
"field_label_ces_average": "Media CES",
|
||||
"field_label_ces_count": "Număr CES",
|
||||
"field_label_collected_at": "Colectat la",
|
||||
"field_label_count": "Număr",
|
||||
"field_label_created_at": "Creat la",
|
||||
"field_label_csat_average": "Media CSAT",
|
||||
"field_label_csat_count": "Număr CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Număr CSAT Nemulțumiți",
|
||||
"field_label_csat_neutral_count": "Număr CSAT Neutri",
|
||||
"field_label_csat_satisfied_count": "Număr clienți mulțumiți CSAT",
|
||||
"field_label_csat_score": "Scor CSAT",
|
||||
"field_label_detractor_count": "Număr de detractori",
|
||||
"field_label_emotion": "Emoție",
|
||||
"field_label_field_type": "Tip câmp",
|
||||
"field_label_language": "Limbă",
|
||||
"field_label_nps_average": "Media NPS",
|
||||
"field_label_nps_score": "Scor NPS",
|
||||
"field_label_nps_value": "Valoare NPS",
|
||||
"field_label_passive_count": "Număr de pasivi",
|
||||
"field_label_promoter_count": "Număr de promotori",
|
||||
"field_label_question": "Întrebare",
|
||||
"field_label_question_group": "Grup de întrebări",
|
||||
"field_label_response_id": "ID răspuns",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Nume sursă",
|
||||
"field_label_source_type": "Tip sursă",
|
||||
"field_label_topic": "Subiect",
|
||||
"field_label_unique_respondents": "Respondenți unici",
|
||||
"field_label_unique_responses": "Răspunsuri unice",
|
||||
"field_label_updated_at": "Actualizat la",
|
||||
"field_label_user_identifier": "Identificator utilizator",
|
||||
"field_label_value_boolean": "Valoare (Boolean)",
|
||||
"field_label_value_date": "Valoare (Dată)",
|
||||
"field_label_value_number": "Valoare (Număr)",
|
||||
"field_label_value_text": "Valoare (Text)",
|
||||
"filter_data": "Filtrează datele",
|
||||
"filters": "Filtre",
|
||||
"filters_toggle_description": "Include doar datele care îndeplinesc următoarele condiții.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adaugă {count} grafic(e)",
|
||||
"chart_removed": "Graficul a fost eliminat din tabloul de bord",
|
||||
"charts_add_failed": "Nu s-au putut adăuga graficele la panoul de control",
|
||||
"charts_add_partial_failure": "Nu s-au putut adăuga {count} grafic(e)",
|
||||
"charts_added_to_dashboard": "Grafice adăugate la panoul de control",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Salvare automată dezactivată",
|
||||
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
|
||||
"auto_save_on": "Salvare automată activată",
|
||||
"auto_select_browser_language": "Folosește implicit limba browserului",
|
||||
"auto_select_browser_language_description": "Deschide automat sondajul în limba browserului respondentului atunci când această limbă este activă. Revine la limba implicită.",
|
||||
"automatically_close_survey_after": "Închideți automat sondajul după",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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.",
|
||||
"delete_source_confirmation": "Ștergerea acestei surse va opri importurile viitoare și va elimina maparea salvată. Înregistrările de feedback existente vor rămâne disponibile.",
|
||||
"discard_feedback_record_changes_description": "Modificările dvs. se vor pierde dacă închideți acest sertar.",
|
||||
"discard_feedback_record_changes_title": "Renunțați la modificările nesalvate?",
|
||||
"drop_a_field_here": "Trage un câmp aici",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Introdu un nume pentru această sursă",
|
||||
"enter_value": "Introdu valoarea...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapare duplicată a câmpului pentru această sursă",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapare duplicată a întrebării pentru această sursă",
|
||||
"error_connector_name_duplicate": "Există deja o sursă cu acest nume",
|
||||
"error_connector_name_required": "Numele sursei este obligatoriu",
|
||||
"error_connector_questions_required": "Selectează cel puțin o întrebare",
|
||||
"error_connector_survey_required": "Selectează un sondaj",
|
||||
"failed_to_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",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Trebuie configurat un director de feedback pentru acest spațiu de lucru înainte ca această funcționalitate să fie disponibilă. Solicită unui proprietar sau manager al organizației să atribuie unul.",
|
||||
"no_feedback_directory_linked_title": "Niciun director de feedback conectat",
|
||||
"no_feedback_records": "Nu există încă înregistrări de feedback. Înregistrările vor apărea aici după ce conectorii tăi vor începe să trimită date.",
|
||||
"no_formbricks_surveys_available_description": "Nu există încă chestionare în acest spațiu de lucru. <surveyLink>Creează un chestionar nou</surveyLink> pentru a folosi unul ca sursă de feedback.",
|
||||
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
|
||||
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
|
||||
"optional": "Opțional",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "например: Сколько пользователей зарегистрировались на прошлой неделе?",
|
||||
"ai_query_section_description": "Опиши, что хочешь увидеть, и AI построит график.",
|
||||
"ai_query_section_title": "Спроси свои данные",
|
||||
"already_on_dashboard": "Уже на дашборде",
|
||||
"and_filter_logic": "И",
|
||||
"apply_changes": "Применить изменения",
|
||||
"chart": "График",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Не удалось сохранить график",
|
||||
"field": "Поле",
|
||||
"field_label_average_score": "Средний балл",
|
||||
"field_label_ces_average": "Средний CES",
|
||||
"field_label_ces_count": "Количество CES",
|
||||
"field_label_collected_at": "Дата сбора",
|
||||
"field_label_count": "Количество",
|
||||
"field_label_created_at": "Дата создания",
|
||||
"field_label_csat_average": "Средний CSAT",
|
||||
"field_label_csat_count": "Количество CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Количество недовольных (CSAT)",
|
||||
"field_label_csat_neutral_count": "Количество нейтральных (CSAT)",
|
||||
"field_label_csat_satisfied_count": "Количество удовлетворённых (CSAT)",
|
||||
"field_label_csat_score": "Оценка CSAT",
|
||||
"field_label_detractor_count": "Количество критиков",
|
||||
"field_label_emotion": "Эмоция",
|
||||
"field_label_field_type": "Тип поля",
|
||||
"field_label_language": "Язык",
|
||||
"field_label_nps_average": "Средний NPS",
|
||||
"field_label_nps_score": "Оценка NPS",
|
||||
"field_label_nps_value": "Значение NPS",
|
||||
"field_label_passive_count": "Количество пассивных",
|
||||
"field_label_promoter_count": "Количество промоутеров",
|
||||
"field_label_question": "Вопрос",
|
||||
"field_label_question_group": "Группа вопросов",
|
||||
"field_label_response_id": "ID ответа",
|
||||
"field_label_sentiment": "Тональность",
|
||||
"field_label_source_name": "Название источника",
|
||||
"field_label_source_type": "Тип источника",
|
||||
"field_label_topic": "Тема",
|
||||
"field_label_unique_respondents": "Уникальные респонденты",
|
||||
"field_label_unique_responses": "Уникальные ответы",
|
||||
"field_label_updated_at": "Дата обновления",
|
||||
"field_label_user_identifier": "Идентификатор пользователя",
|
||||
"field_label_value_boolean": "Значение (логическое)",
|
||||
"field_label_value_date": "Значение (дата)",
|
||||
"field_label_value_number": "Значение (число)",
|
||||
"field_label_value_text": "Значение (текст)",
|
||||
"filter_data": "Фильтровать данные",
|
||||
"filters": "Фильтры",
|
||||
"filters_toggle_description": "Включай только те данные, которые соответствуют следующим условиям.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Добавить {count} график(ов)",
|
||||
"chart_removed": "График удалён с панели",
|
||||
"charts_add_failed": "Не удалось добавить графики на дашборд",
|
||||
"charts_add_partial_failure": "Не удалось добавить {count} график(ов)",
|
||||
"charts_added_to_dashboard": "Графики добавлены на дашборд",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Автосохранение отключено",
|
||||
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
|
||||
"auto_save_on": "Автосохранение включено",
|
||||
"auto_select_browser_language": "Использовать язык браузера по умолчанию",
|
||||
"auto_select_browser_language_description": "Автоматически открывает опрос на языке браузера респондента, если этот язык активен. В противном случае используется язык по умолчанию.",
|
||||
"automatically_close_survey_after": "Автоматически закрыть опрос через",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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 {записей обратной связи}} и уберёт их из подключённого каталога.",
|
||||
"delete_source_confirmation": "Удаление этого источника остановит будущие импорты и удалит сохранённое сопоставление. Существующие записи обратной связи останутся доступны.",
|
||||
"discard_feedback_record_changes_description": "Ваши изменения будут потеряны, если вы закроете этот ящик.",
|
||||
"discard_feedback_record_changes_title": "Отменить несохраненные изменения?",
|
||||
"drop_a_field_here": "Перетащи сюда поле",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Введи имя для этого источника",
|
||||
"enter_value": "Введите значение...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Дублирующееся сопоставление полей для этого источника",
|
||||
"error_connector_formbricks_mapping_duplicate": "Дублирующееся сопоставление вопросов для этого источника",
|
||||
"error_connector_name_duplicate": "Источник с таким именем уже существует",
|
||||
"error_connector_name_required": "Необходимо указать название источника",
|
||||
"error_connector_questions_required": "Выберите хотя бы один вопрос",
|
||||
"error_connector_survey_required": "Выберите опрос",
|
||||
"failed_to_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": "Поля записи отзыва",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Для этого рабочего пространства нужно настроить директорию обратной связи, прежде чем эта функция станет доступна. Попроси владельца или менеджера организации назначить её.",
|
||||
"no_feedback_directory_linked_title": "Директория обратной связи не привязана",
|
||||
"no_feedback_records": "Пока нет записей отзывов. Они появятся здесь, когда коннекторы начнут отправлять данные.",
|
||||
"no_formbricks_surveys_available_description": "В этом рабочем пространстве пока нет опросов. <surveyLink>Создайте новый опрос</surveyLink>, чтобы использовать один как источник обратной связи.",
|
||||
"no_source_fields_loaded": "Поля источника ещё не загружены",
|
||||
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
|
||||
"optional": "Необязательно",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "t.ex. Hur många användare registrerade sig förra veckan?",
|
||||
"ai_query_section_description": "Beskriv vad du vill se så bygger AI diagrammet åt dig.",
|
||||
"ai_query_section_title": "Fråga din data",
|
||||
"already_on_dashboard": "Redan på instrumentpanelen",
|
||||
"and_filter_logic": "OCH",
|
||||
"apply_changes": "Verkställ ändringar",
|
||||
"chart": "Diagram",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Det gick inte att spara diagrammet",
|
||||
"field": "Fält",
|
||||
"field_label_average_score": "Genomsnittligt betyg",
|
||||
"field_label_ces_average": "CES-medelvärde",
|
||||
"field_label_ces_count": "CES-antal",
|
||||
"field_label_collected_at": "Insamlad",
|
||||
"field_label_count": "Antal",
|
||||
"field_label_created_at": "Skapad",
|
||||
"field_label_csat_average": "CSAT-medelvärde",
|
||||
"field_label_csat_count": "CSAT-antal",
|
||||
"field_label_csat_dissatisfied_count": "CSAT-antal missnöjda",
|
||||
"field_label_csat_neutral_count": "CSAT-antal neutrala",
|
||||
"field_label_csat_satisfied_count": "CSAT antal nöjda",
|
||||
"field_label_csat_score": "CSAT-poäng",
|
||||
"field_label_detractor_count": "Antal kritiker",
|
||||
"field_label_emotion": "Känsla",
|
||||
"field_label_field_type": "Fälttyp",
|
||||
"field_label_language": "Språk",
|
||||
"field_label_nps_average": "NPS-medelvärde",
|
||||
"field_label_nps_score": "NPS-poäng",
|
||||
"field_label_nps_value": "NPS-värde",
|
||||
"field_label_passive_count": "Antal passiva",
|
||||
"field_label_promoter_count": "Antal förespråkare",
|
||||
"field_label_question": "Fråga",
|
||||
"field_label_question_group": "Frågegrupp",
|
||||
"field_label_response_id": "Svar-ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Källnamn",
|
||||
"field_label_source_type": "Källtyp",
|
||||
"field_label_topic": "Ämne",
|
||||
"field_label_unique_respondents": "Unika respondenter",
|
||||
"field_label_unique_responses": "Unika svar",
|
||||
"field_label_updated_at": "Uppdaterad",
|
||||
"field_label_user_identifier": "Användar-ID",
|
||||
"field_label_value_boolean": "Värde (Boolean)",
|
||||
"field_label_value_date": "Värde (Datum)",
|
||||
"field_label_value_number": "Värde (Nummer)",
|
||||
"field_label_value_text": "Värde (Text)",
|
||||
"filter_data": "Filtrera data",
|
||||
"filters": "Filter",
|
||||
"filters_toggle_description": "Inkludera bara data som uppfyller följande villkor.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Lägg till {count} diagram",
|
||||
"chart_removed": "Diagram borttaget från instrumentpanelen",
|
||||
"charts_add_failed": "Misslyckades med att lägga till diagram i instrumentpanelen",
|
||||
"charts_add_partial_failure": "Misslyckades med att lägga till {count} diagram",
|
||||
"charts_added_to_dashboard": "Diagram tillagda i instrumentpanelen",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Automatisk sparning inaktiverad",
|
||||
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
|
||||
"auto_save_on": "Automatisk sparning på",
|
||||
"auto_select_browser_language": "Använd webbläsarens språk som standard",
|
||||
"auto_select_browser_language_description": "Öppnar automatiskt enkäten på respondentens webbläsarspråk när det språket är aktivt. Faller tillbaka till standardspråket.",
|
||||
"automatically_close_survey_after": "Stäng enkäten automatiskt efter",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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.",
|
||||
"delete_source_confirmation": "Att ta bort den här källan kommer att stoppa framtida importer och ta bort dess sparade mappning. Befintliga feedbackposter kommer att förbli tillgängliga.",
|
||||
"discard_feedback_record_changes_description": "Dina ändringar kommer att gå förlorade om du stänger den här lådan.",
|
||||
"discard_feedback_record_changes_title": "Vill du ignorera osparade ändringar?",
|
||||
"drop_a_field_here": "Släpp ett fält här",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Ange ett namn för denna källa",
|
||||
"enter_value": "Ange värde...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Duplicerad fältmappning för denna källa",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplicerad frågemappning för denna källa",
|
||||
"error_connector_name_duplicate": "En källa med det här namnet finns redan",
|
||||
"error_connector_name_required": "Källnamn krävs",
|
||||
"error_connector_questions_required": "Välj minst en fråga",
|
||||
"error_connector_survey_required": "Välj en undersökning",
|
||||
"failed_to_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",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "En feedbackkatalog måste konfigureras för denna arbetsyta innan den här funktionen blir tillgänglig. Be en organisationsägare eller chef att tilldela en.",
|
||||
"no_feedback_directory_linked_title": "Ingen feedbackkatalog länkad",
|
||||
"no_feedback_records": "Inga feedbackposter ännu. Poster visas här när dina connectors börjar skicka data.",
|
||||
"no_formbricks_surveys_available_description": "Det finns inga enkäter i denna arbetsyta ännu. <surveyLink>Skapa en ny enkät</surveyLink> för att använda en som feedbackkälla.",
|
||||
"no_source_fields_loaded": "Inga källfält har laddats än",
|
||||
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
|
||||
"optional": "Valfritt",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "örn. Geçen hafta kaç kullanıcı kaydoldu?",
|
||||
"ai_query_section_description": "Ne görmek istediğini anlat, AI grafiği oluştursun.",
|
||||
"ai_query_section_title": "Verilerine sor",
|
||||
"already_on_dashboard": "Zaten panoda",
|
||||
"and_filter_logic": "VE",
|
||||
"apply_changes": "Değişiklikleri Uygula",
|
||||
"chart": "Grafik",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Grafik kaydedilemedi",
|
||||
"field": "Alan",
|
||||
"field_label_average_score": "Ortalama Puan",
|
||||
"field_label_ces_average": "CES Ortalaması",
|
||||
"field_label_ces_count": "CES Sayısı",
|
||||
"field_label_collected_at": "Toplandığı Tarih",
|
||||
"field_label_count": "Sayı",
|
||||
"field_label_created_at": "Oluşturulma Tarihi",
|
||||
"field_label_csat_average": "CSAT Ortalaması",
|
||||
"field_label_csat_count": "CSAT Sayısı",
|
||||
"field_label_csat_dissatisfied_count": "CSAT Memnuniyetsiz Sayısı",
|
||||
"field_label_csat_neutral_count": "CSAT Nötr Sayısı",
|
||||
"field_label_csat_satisfied_count": "CSAT Memnun Sayısı",
|
||||
"field_label_csat_score": "CSAT Puanı",
|
||||
"field_label_detractor_count": "Eleştirmen Sayısı",
|
||||
"field_label_emotion": "Duygu",
|
||||
"field_label_field_type": "Alan Türü",
|
||||
"field_label_language": "Dil",
|
||||
"field_label_nps_average": "NPS Ortalaması",
|
||||
"field_label_nps_score": "NPS Puanı",
|
||||
"field_label_nps_value": "NPS Değeri",
|
||||
"field_label_passive_count": "Pasif Sayısı",
|
||||
"field_label_promoter_count": "Tavsiye Eden Sayısı",
|
||||
"field_label_question": "Soru",
|
||||
"field_label_question_group": "Soru Grubu",
|
||||
"field_label_response_id": "Yanıt Kimliği",
|
||||
"field_label_sentiment": "Duygu Durumu",
|
||||
"field_label_source_name": "Kaynak Adı",
|
||||
"field_label_source_type": "Kaynak Türü",
|
||||
"field_label_topic": "Konu",
|
||||
"field_label_unique_respondents": "Benzersiz Katılımcılar",
|
||||
"field_label_unique_responses": "Benzersiz Yanıtlar",
|
||||
"field_label_updated_at": "Güncellenme Tarihi",
|
||||
"field_label_user_identifier": "Kullanıcı Tanımlayıcısı",
|
||||
"field_label_value_boolean": "Değer (Boolean)",
|
||||
"field_label_value_date": "Değer (Tarih)",
|
||||
"field_label_value_number": "Değer (Sayı)",
|
||||
"field_label_value_text": "Değer (Metin)",
|
||||
"filter_data": "Verileri filtrele",
|
||||
"filters": "Filtreler",
|
||||
"filters_toggle_description": "Yalnızca aşağıdaki koşulları karşılayan verileri dahil et.",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} grafik ekle",
|
||||
"chart_removed": "Grafik gösterge panosundan kaldırıldı",
|
||||
"charts_add_failed": "Grafikler panoya eklenemedi",
|
||||
"charts_add_partial_failure": "{count} grafik eklenemedi",
|
||||
"charts_added_to_dashboard": "Grafikler panoya eklendi",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "Otomatik kaydetme devre dışı",
|
||||
"auto_save_disabled_tooltip": "Anketin otomatik olarak kaydedilmesi yalnızca taslak durumdayken çalışır. Bu, yayınlanmış anketlerin istemeden güncellenmemesini sağlar.",
|
||||
"auto_save_on": "Otomatik kaydetme açık",
|
||||
"auto_select_browser_language": "Varsayılan olarak tarayıcı dilini kullan",
|
||||
"auto_select_browser_language_description": "Bu dil aktif olduğunda anketi otomatik olarak yanıtlayıcının tarayıcı dilinde açar. Eşleşme yoksa varsayılan dile döner.",
|
||||
"automatically_close_survey_after": "Anketi otomatik olarak kapat",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Belirli sayıda yanıt alındıktan sonra anketi otomatik olarak kapat.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Kullanıcı belirli saniye içinde yanıt vermezse anketi otomatik olarak kapat.",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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.",
|
||||
"delete_source_confirmation": "Bu kaynağı silmek, gelecekteki içe aktarmaları durduracak ve kayıtlı eşleştirmesini kaldıracak. Mevcut geri bildirim kayıtları erişilebilir durumda kalacak.",
|
||||
"discard_feedback_record_changes_description": "Bu çekmeceyi kapatırsanız değişiklikleriniz kaybolacak.",
|
||||
"discard_feedback_record_changes_title": "Kaydedilmemiş değişiklikler silinsin mi?",
|
||||
"drop_a_field_here": "Buraya bir alan bırakın",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "Bu kaynak için bir ad girin",
|
||||
"enter_value": "Değer girin...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Bu kaynak için yinelenen alan eşlemesi",
|
||||
"error_connector_formbricks_mapping_duplicate": "Bu kaynak için yinelenen soru eşlemesi",
|
||||
"error_connector_name_duplicate": "Bu isimde bir kaynak zaten mevcut",
|
||||
"error_connector_name_required": "Kaynak adı gereklidir",
|
||||
"error_connector_questions_required": "En az bir soru seçin",
|
||||
"error_connector_survey_required": "Bir anket seçin",
|
||||
"failed_to_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ı",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Bu işlevin kullanılabilmesi için önce bu çalışma alanı için bir geri bildirim dizini kurulması gerekiyor. Bir organizasyon sahibinden veya yöneticisinden bir tane atamasını iste.",
|
||||
"no_feedback_directory_linked_title": "Bağlı geri bildirim dizini yok",
|
||||
"no_feedback_records": "Henüz geri bildirim kaydı yok. Bağlayıcıların veri göndermeye başlamasıyla kayıtlar burada görünecek.",
|
||||
"no_formbricks_surveys_available_description": "Bu çalışma alanında henüz anket yok. Geri bildirim kaynağı olarak kullanmak için <surveyLink>Yeni bir anket oluştur</surveyLink>.",
|
||||
"no_source_fields_loaded": "Henüz kaynak alan yüklenmedi",
|
||||
"no_sources_connected": "Henüz bağlı kaynak yok. Başlamak için bir kaynak ekle.",
|
||||
"optional": "İsteğe bağlı",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "例如:上周有多少用户注册?",
|
||||
"ai_query_section_description": "描述你想要看到的内容,让 AI 帮你生成图表。",
|
||||
"ai_query_section_title": "向你的数据提问",
|
||||
"already_on_dashboard": "已在仪表板上",
|
||||
"and_filter_logic": "且",
|
||||
"apply_changes": "应用更改",
|
||||
"chart": "图表",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "图表保存失败",
|
||||
"field": "字段",
|
||||
"field_label_average_score": "平均分",
|
||||
"field_label_ces_average": "CES 平均值",
|
||||
"field_label_ces_count": "CES 数量",
|
||||
"field_label_collected_at": "收集时间",
|
||||
"field_label_count": "数量",
|
||||
"field_label_created_at": "创建时间",
|
||||
"field_label_csat_average": "CSAT 平均值",
|
||||
"field_label_csat_count": "CSAT 数量",
|
||||
"field_label_csat_dissatisfied_count": "CSAT 不满意数量",
|
||||
"field_label_csat_neutral_count": "CSAT 中立数量",
|
||||
"field_label_csat_satisfied_count": "CSAT 满意数量",
|
||||
"field_label_csat_score": "CSAT 得分",
|
||||
"field_label_detractor_count": "贬损者数量",
|
||||
"field_label_emotion": "情感",
|
||||
"field_label_field_type": "字段类型",
|
||||
"field_label_language": "语言",
|
||||
"field_label_nps_average": "NPS 平均值",
|
||||
"field_label_nps_score": "NPS 得分",
|
||||
"field_label_nps_value": "NPS 值",
|
||||
"field_label_passive_count": "中立者数量",
|
||||
"field_label_promoter_count": "推荐者数量",
|
||||
"field_label_question": "问题",
|
||||
"field_label_question_group": "问题组",
|
||||
"field_label_response_id": "响应 ID",
|
||||
"field_label_sentiment": "情绪",
|
||||
"field_label_source_name": "来源名称",
|
||||
"field_label_source_type": "来源类型",
|
||||
"field_label_topic": "主题",
|
||||
"field_label_unique_respondents": "独立受访者",
|
||||
"field_label_unique_responses": "独立回复",
|
||||
"field_label_updated_at": "更新时间",
|
||||
"field_label_user_identifier": "用户标识",
|
||||
"field_label_value_boolean": "值(布尔型)",
|
||||
"field_label_value_date": "值(日期)",
|
||||
"field_label_value_number": "数值",
|
||||
"field_label_value_text": "文本值",
|
||||
"filter_data": "筛选数据",
|
||||
"filters": "筛选条件",
|
||||
"filters_toggle_description": "仅包含符合以下条件的数据。",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "添加 {count} 个图表",
|
||||
"chart_removed": "图表已从仪表板中移除",
|
||||
"charts_add_failed": "添加图表到仪表板失败",
|
||||
"charts_add_partial_failure": "添加 {count} 个图表失败",
|
||||
"charts_added_to_dashboard": "图表已添加到仪表板",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "自动保存已禁用",
|
||||
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
|
||||
"auto_save_on": "自动保存已启用",
|
||||
"auto_select_browser_language": "默认使用浏览器语言",
|
||||
"auto_select_browser_language_description": "当受访者的浏览器语言处于启用状态时,自动以该语言打开调查。否则回退到默认语言。",
|
||||
"automatically_close_survey_after": "自动 关闭 调查 后",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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} 条反馈记录并从关联目录中移除。",
|
||||
"delete_source_confirmation": "删除此数据源将停止未来的导入并移除其保存的映射。现有的反馈记录将保持可用。",
|
||||
"discard_feedback_record_changes_description": "如果关闭此抽屉,您的更改将会丢失。",
|
||||
"discard_feedback_record_changes_title": "放弃未保存的更改?",
|
||||
"drop_a_field_here": "将字段拖到这里",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "为此来源输入名称",
|
||||
"enter_value": "请输入值...",
|
||||
"enum": "枚举",
|
||||
"error_connector_field_mapping_duplicate": "此来源存在重复的字段映射",
|
||||
"error_connector_formbricks_mapping_duplicate": "此来源存在重复的问题映射",
|
||||
"error_connector_name_duplicate": "该名称的数据源已存在",
|
||||
"error_connector_name_required": "数据源名称为必填项",
|
||||
"error_connector_questions_required": "请至少选择一个问题",
|
||||
"error_connector_survey_required": "请选择一个调查问卷",
|
||||
"failed_to_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": "反馈记录字段",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "在该功能可用前,需要先为此工作区设置反馈目录。请联系组织所有者或管理员进行分配。",
|
||||
"no_feedback_directory_linked_title": "未关联反馈目录",
|
||||
"no_feedback_records": "暂无反馈记录。当你的连接器开始发送数据后,记录会显示在这里。",
|
||||
"no_formbricks_surveys_available_description": "此工作区还没有调查。<surveyLink>创建新调查</surveyLink>,以将其用作反馈来源。",
|
||||
"no_source_fields_loaded": "尚未加载源字段",
|
||||
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
|
||||
"optional": "可选",
|
||||
|
||||
@@ -1669,6 +1669,7 @@
|
||||
"ai_query_placeholder": "例如:上週有多少用戶註冊?",
|
||||
"ai_query_section_description": "描述你想看到的內容,讓 AI 幫你建立圖表。",
|
||||
"ai_query_section_title": "詢問你的數據",
|
||||
"already_on_dashboard": "已在儀表板上",
|
||||
"and_filter_logic": "且",
|
||||
"apply_changes": "套用變更",
|
||||
"chart": "圖表",
|
||||
@@ -1730,22 +1731,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "儲存圖表失敗",
|
||||
"field": "欄位",
|
||||
"field_label_average_score": "平均分數",
|
||||
"field_label_ces_average": "CES 平均值",
|
||||
"field_label_ces_count": "CES 數量",
|
||||
"field_label_collected_at": "收集時間",
|
||||
"field_label_count": "數量",
|
||||
"field_label_created_at": "建立時間",
|
||||
"field_label_csat_average": "CSAT 平均值",
|
||||
"field_label_csat_count": "CSAT 數量",
|
||||
"field_label_csat_dissatisfied_count": "CSAT 不滿意數量",
|
||||
"field_label_csat_neutral_count": "CSAT 中立數量",
|
||||
"field_label_csat_satisfied_count": "CSAT 滿意數量",
|
||||
"field_label_csat_score": "CSAT 分數",
|
||||
"field_label_detractor_count": "批評者數量",
|
||||
"field_label_emotion": "情緒",
|
||||
"field_label_field_type": "欄位類型",
|
||||
"field_label_language": "語言",
|
||||
"field_label_nps_average": "NPS 平均值",
|
||||
"field_label_nps_score": "NPS 分數",
|
||||
"field_label_nps_value": "NPS 值",
|
||||
"field_label_passive_count": "中立者數量",
|
||||
"field_label_promoter_count": "推廣者數量",
|
||||
"field_label_question": "問題",
|
||||
"field_label_question_group": "問題群組",
|
||||
"field_label_response_id": "回應 ID",
|
||||
"field_label_sentiment": "情感",
|
||||
"field_label_source_name": "來源名稱",
|
||||
"field_label_source_type": "來源類型",
|
||||
"field_label_topic": "主題",
|
||||
"field_label_unique_respondents": "不重複受訪者",
|
||||
"field_label_unique_responses": "不重複回應",
|
||||
"field_label_updated_at": "更新時間",
|
||||
"field_label_user_identifier": "使用者識別碼",
|
||||
"field_label_value_boolean": "值(布林)",
|
||||
"field_label_value_date": "值(日期)",
|
||||
"field_label_value_number": "數值",
|
||||
"field_label_value_text": "文字值",
|
||||
"filter_data": "篩選資料",
|
||||
"filters": "篩選條件",
|
||||
"filters_toggle_description": "只包含符合下列條件的資料。",
|
||||
@@ -1810,6 +1826,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "新增 {count} 個圖表",
|
||||
"chart_removed": "圖表已從儀表板移除",
|
||||
"charts_add_failed": "無法將圖表新增至儀表板",
|
||||
"charts_add_partial_failure": "無法新增 {count} 個圖表",
|
||||
"charts_added_to_dashboard": "圖表已新增至儀表板",
|
||||
@@ -2816,6 +2833,8 @@
|
||||
"auto_save_disabled": "自動儲存已停用",
|
||||
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
|
||||
"auto_save_on": "自動儲存已啟用",
|
||||
"auto_select_browser_language": "預設使用瀏覽器語言",
|
||||
"auto_select_browser_language_description": "當受訪者的瀏覽器語言已啟用時,自動以該語言開啟問卷。否則會回到預設語言。",
|
||||
"automatically_close_survey_after": "在指定時間自動關閉問卷",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
|
||||
@@ -3680,6 +3699,10 @@
|
||||
"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} 筆意見回饋記錄,並從已連結的目錄中移除。",
|
||||
"delete_source_confirmation": "刪除此來源將停止未來的匯入並移除其已儲存的對應設定。現有的意見回饋記錄將保持可用。",
|
||||
"discard_feedback_record_changes_description": "如果關閉此抽屜,您的變更將會遺失。",
|
||||
"discard_feedback_record_changes_title": "放棄未儲存的變更?",
|
||||
"drop_a_field_here": "請將欄位拖曳到這裡",
|
||||
@@ -3689,10 +3712,18 @@
|
||||
"enter_name_for_source": "請輸入此來源的名稱",
|
||||
"enter_value": "請輸入值……",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "此來源的欄位對應重複",
|
||||
"error_connector_formbricks_mapping_duplicate": "此來源的問題對應重複",
|
||||
"error_connector_name_duplicate": "已存在使用此名稱的來源",
|
||||
"error_connector_name_required": "來源名稱為必填項目",
|
||||
"error_connector_questions_required": "請至少選擇一個問題",
|
||||
"error_connector_survey_required": "請選擇一個調查問卷",
|
||||
"failed_to_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": "回饋紀錄欄位",
|
||||
@@ -3700,6 +3731,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}",
|
||||
@@ -3736,6 +3769,7 @@
|
||||
"no_feedback_directory_linked_member_description": "此工作區需要先設定意見回饋目錄,才能使用此功能。請請組織擁有者或管理員指定一個目錄。",
|
||||
"no_feedback_directory_linked_title": "未連結意見回饋目錄",
|
||||
"no_feedback_records": "目前尚無回饋紀錄。當你的連接器開始傳送資料時,紀錄會顯示在這裡。",
|
||||
"no_formbricks_surveys_available_description": "此工作區尚無問卷。<surveyLink>建立新問卷</surveyLink>,以將其用作回饋來源。",
|
||||
"no_source_fields_loaded": "尚未載入來源欄位",
|
||||
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
|
||||
"optional": "選填",
|
||||
|
||||
@@ -51,6 +51,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
|
||||
styling: true,
|
||||
workspaceOverwrites: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
})
|
||||
.partial({
|
||||
redirectUrl: true,
|
||||
@@ -66,6 +67,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
|
||||
styling: true,
|
||||
workspaceOverwrites: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
inlineTriggers: true,
|
||||
displayPercentage: true,
|
||||
})
|
||||
|
||||
@@ -187,7 +187,7 @@ describe("executeTenantScopedQuery", () => {
|
||||
...scopedInput,
|
||||
query: {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["secret-value"] }],
|
||||
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("executeTenantScopedQuery", () => {
|
||||
targetType: "cubeQuery",
|
||||
newObject: expect.objectContaining({
|
||||
query: expect.objectContaining({
|
||||
filterMembers: ["FeedbackRecords.sentiment"],
|
||||
filterMembers: ["FeedbackRecords.sourceType"],
|
||||
filterCount: 1,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -36,6 +36,16 @@ describe("cube queryRewrite", () => {
|
||||
expect(() => queryRewrite({ measures: ["FeedbackRecords.count"] }, {})).toThrow(
|
||||
/missing tenantId security context/
|
||||
);
|
||||
|
||||
const logPayload = vi.mocked(console.log).mock.calls[0][0];
|
||||
const parsed = JSON.parse(logPayload);
|
||||
expect(parsed).toMatchObject({
|
||||
type: "audit",
|
||||
event: "cube.query",
|
||||
status: "failure",
|
||||
errorName: "Error",
|
||||
errorMessage: "Cube query rejected: missing tenantId security context",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects Cube startup without an API secret", () => {
|
||||
@@ -147,7 +157,7 @@ describe("cube queryRewrite", () => {
|
||||
filters: [
|
||||
{
|
||||
or: [
|
||||
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["workspace-2"] },
|
||||
],
|
||||
},
|
||||
@@ -185,13 +195,13 @@ describe("cube queryRewrite", () => {
|
||||
test("appends the mandatory tenant filter from security context", () => {
|
||||
const query = {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
|
||||
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] }],
|
||||
};
|
||||
|
||||
const rewrittenQuery = queryRewrite(query, { securityContext });
|
||||
|
||||
expect(rewrittenQuery.filters).toEqual([
|
||||
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["frd-1"] },
|
||||
]);
|
||||
expect(query.filters).toHaveLength(1);
|
||||
@@ -201,7 +211,7 @@ describe("cube queryRewrite", () => {
|
||||
queryRewrite(
|
||||
{
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["secret-value"] }],
|
||||
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
|
||||
},
|
||||
{ securityContext }
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ describe("cube-query", () => {
|
||||
expect(() =>
|
||||
validateCubeQueryMembers({
|
||||
measures: ["FeedbackRecords.count"],
|
||||
dimensions: ["FeedbackRecords.sentiment"],
|
||||
dimensions: ["FeedbackRecords.sourceType"],
|
||||
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt" }],
|
||||
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
|
||||
order: { "FeedbackRecords.collectedAt": "desc" },
|
||||
@@ -18,10 +18,6 @@ describe("cube-query", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("allows TopicsUnnested dimensions from joined cube", () => {
|
||||
expect(() => validateCubeQueryMembers({ dimensions: ["TopicsUnnested.topic"] })).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for invalid members across query sections", () => {
|
||||
expect(() =>
|
||||
validateCubeQueryMembers({
|
||||
@@ -57,7 +53,7 @@ describe("cube-query", () => {
|
||||
filters: [
|
||||
{
|
||||
or: [
|
||||
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
|
||||
{
|
||||
and: [{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["workspace-2"] }],
|
||||
},
|
||||
@@ -90,7 +86,7 @@ describe("cube-query", () => {
|
||||
expect(() =>
|
||||
validateCubeQueryMembers({
|
||||
measures: ["FeedbackRecords.count", null],
|
||||
dimensions: [{ member: "FeedbackRecords.sentiment" }],
|
||||
dimensions: [{ member: "FeedbackRecords.sourceType" }],
|
||||
segments: [0],
|
||||
timeDimensions: [null, { dimension: null }],
|
||||
filters: [null, { member: { name: "FeedbackRecords.sourceType" } }, { and: [0] }, { or: "bad" }],
|
||||
@@ -103,7 +99,7 @@ describe("cube-query", () => {
|
||||
test("summarizes query members without raw filter values", () => {
|
||||
const summary = getCubeQueryAuditSummary({
|
||||
measures: ["FeedbackRecords.count"],
|
||||
dimensions: ["FeedbackRecords.sentiment"],
|
||||
dimensions: ["FeedbackRecords.sourceType"],
|
||||
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
|
||||
order: [["FeedbackRecords.collectedAt", "desc"]],
|
||||
limit: 50,
|
||||
@@ -111,7 +107,7 @@ describe("cube-query", () => {
|
||||
|
||||
expect(summary).toEqual({
|
||||
measures: ["FeedbackRecords.count"],
|
||||
dimensions: ["FeedbackRecords.sentiment"],
|
||||
dimensions: ["FeedbackRecords.sourceType"],
|
||||
segments: [],
|
||||
timeDimensions: [],
|
||||
filterMembers: ["FeedbackRecords.sourceType"],
|
||||
@@ -125,12 +121,12 @@ describe("cube-query", () => {
|
||||
test("summarizes only valid member names from malformed query shapes", () => {
|
||||
const summary = getCubeQueryAuditSummary({
|
||||
measures: ["FeedbackRecords.count", null],
|
||||
dimensions: [{ member: "FeedbackRecords.sentiment" }],
|
||||
dimensions: [{ member: "FeedbackRecords.sourceType" }],
|
||||
timeDimensions: [null, { dimension: "FeedbackRecords.collectedAt" }],
|
||||
filters: [
|
||||
null,
|
||||
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] },
|
||||
{ and: [0, { member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }] },
|
||||
{ and: [0, { member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] }] },
|
||||
],
|
||||
order: [
|
||||
[null, "asc"],
|
||||
@@ -143,7 +139,7 @@ describe("cube-query", () => {
|
||||
dimensions: [],
|
||||
segments: [],
|
||||
timeDimensions: ["FeedbackRecords.collectedAt"],
|
||||
filterMembers: ["FeedbackRecords.sentiment", "FeedbackRecords.sourceType"],
|
||||
filterMembers: ["FeedbackRecords.sourceType"],
|
||||
filterCount: 2,
|
||||
orderMembers: ["FeedbackRecords.collectedAt"],
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
|
||||
export const TENANT_MEMBER = "FeedbackRecords.tenantId";
|
||||
|
||||
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords.", "TopicsUnnested."];
|
||||
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords."];
|
||||
const INVALID_MEMBER_REFERENCE = "invalid member reference";
|
||||
|
||||
type TQueryAuditSummary = {
|
||||
@@ -310,9 +310,9 @@ export const validateCubeQueryMembers = (query: TChartQuery): void => {
|
||||
|
||||
if (result.invalidMembers.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid query members (must start with FeedbackRecords. or TopicsUnnested.): ${uniqueSorted(
|
||||
result.invalidMembers
|
||||
).join(", ")}`
|
||||
`Invalid query members (must start with FeedbackRecords.): ${uniqueSorted(result.invalidMembers).join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("chart Cube actions", () => {
|
||||
mocks.generateText.mockResolvedValue({
|
||||
output: {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
dimensions: ["FeedbackRecords.sentiment"],
|
||||
dimensions: ["FeedbackRecords.sourceType"],
|
||||
timeDimensions: null,
|
||||
chartType: "bar",
|
||||
filters: null,
|
||||
@@ -198,7 +198,7 @@ describe("chart Cube actions", () => {
|
||||
expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith({
|
||||
query: {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
dimensions: ["FeedbackRecords.sentiment"],
|
||||
dimensions: ["FeedbackRecords.sourceType"],
|
||||
},
|
||||
feedbackDirectoryId: "frd-1",
|
||||
workspaceId: "workspace-1",
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
} from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { checkFeedbackDirectoryAccess, checkWorkspaceAccess } from "@/modules/ee/analysis/lib/access";
|
||||
import { generateSchemaContext } from "@/modules/ee/analysis/lib/ai-schema-context";
|
||||
import {
|
||||
FEEDBACK_DIMENSION_IDS,
|
||||
FEEDBACK_MEASURE_IDS,
|
||||
FEEDBACK_TIME_DIMENSION_IDS,
|
||||
} from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { ZChartCreateInput, ZChartType, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsDashboardsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -287,13 +292,25 @@ export const executeQueryAction = authenticatedActionClient
|
||||
|
||||
const CUBE_NAME = "FeedbackRecords";
|
||||
|
||||
const toEnumTuple = (values: readonly string[]): [string, ...string[]] => {
|
||||
if (values.length === 0) {
|
||||
throw new Error("AI query schema requires at least one allowed id");
|
||||
}
|
||||
return [values[0], ...values.slice(1)];
|
||||
};
|
||||
|
||||
const ZMeasureId = z.enum(toEnumTuple(FEEDBACK_MEASURE_IDS));
|
||||
const ZDimensionId = z.enum(toEnumTuple(FEEDBACK_DIMENSION_IDS));
|
||||
const ZTimeDimensionId = z.enum(toEnumTuple(FEEDBACK_TIME_DIMENSION_IDS));
|
||||
const ZFilterMemberId = z.enum(toEnumTuple([...FEEDBACK_MEASURE_IDS, ...FEEDBACK_DIMENSION_IDS]));
|
||||
|
||||
const ZGenerateAIQueryResponse = z.object({
|
||||
measures: z.array(z.string()),
|
||||
dimensions: z.array(z.string()).nullable(),
|
||||
measures: z.array(ZMeasureId),
|
||||
dimensions: z.array(ZDimensionId).nullable(),
|
||||
timeDimensions: z
|
||||
.array(
|
||||
z.object({
|
||||
dimension: z.string(),
|
||||
dimension: ZTimeDimensionId,
|
||||
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).nullable(),
|
||||
dateRange: z.string().nullable(),
|
||||
})
|
||||
@@ -303,7 +320,7 @@ const ZGenerateAIQueryResponse = z.object({
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
member: z.string(),
|
||||
member: ZFilterMemberId,
|
||||
operator: z.enum([
|
||||
"equals",
|
||||
"notEquals",
|
||||
@@ -364,6 +381,7 @@ export const generateAIChartAction = authenticatedActionClient
|
||||
output: Output.object({ schema: ZGenerateAIQueryResponse }),
|
||||
system: schemaContext,
|
||||
prompt: `User request: "${parsedInput.prompt}"`,
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
const measures = output.measures.length > 0 ? output.measures : [`${CUBE_NAME}.count`];
|
||||
|
||||
@@ -26,7 +26,7 @@ interface AddToDashboardDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
dashboards: Array<{ id: string; name: string }>;
|
||||
dashboards: Array<{ id: string; name: string; containsChart?: boolean }>;
|
||||
selectedDashboardId: string | undefined;
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onConfirm: () => void;
|
||||
@@ -89,8 +89,13 @@ export function AddToDashboardDialog({
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="max-h-[200px]">
|
||||
{dashboards.map((dashboard) => (
|
||||
<SelectItem key={dashboard.id} value={dashboard.id}>
|
||||
<SelectItem key={dashboard.id} value={dashboard.id} disabled={dashboard.containsChart}>
|
||||
{dashboard.name}
|
||||
{dashboard.containsChart && (
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
({t("workspace.analysis.charts.already_on_dashboard")})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { DialogFooter } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartDialogFooterProps {
|
||||
onSaveClick: () => void;
|
||||
onSaveClick?: () => void;
|
||||
formId?: string;
|
||||
onAddToDashboardClick?: () => void;
|
||||
isSaving: boolean;
|
||||
saveLabel?: string;
|
||||
@@ -15,6 +16,7 @@ interface ChartDialogFooterProps {
|
||||
|
||||
export function ChartDialogFooter({
|
||||
onSaveClick,
|
||||
formId,
|
||||
onAddToDashboardClick,
|
||||
isSaving,
|
||||
saveLabel,
|
||||
@@ -24,12 +26,16 @@ export function ChartDialogFooter({
|
||||
return (
|
||||
<DialogFooter>
|
||||
{showAddToDashboard && onAddToDashboardClick && (
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<Button variant="outline" type="button" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSaveClick} disabled={isSaving}>
|
||||
<Button
|
||||
type={formId ? "submit" : "button"}
|
||||
form={formId}
|
||||
onClick={formId ? undefined : onSaveClick}
|
||||
disabled={isSaving}>
|
||||
<SaveIcon className="mr-2 h-4 w-4" />
|
||||
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
|
||||
@@ -35,7 +35,9 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [isAddingToDashboard, setIsAddingToDashboard] = useState(false);
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string; containsChart?: boolean }>>(
|
||||
[]
|
||||
);
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,14 +49,20 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
};
|
||||
}
|
||||
|
||||
void getDashboardsAction({ workspaceId })
|
||||
void getDashboardsAction({ workspaceId, chartId: chart.id })
|
||||
.then((result) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.data) {
|
||||
setDashboards(result.data.map((dashboard) => ({ id: dashboard.id, name: dashboard.name })));
|
||||
setDashboards(
|
||||
result.data.map((dashboard) => ({
|
||||
id: dashboard.id,
|
||||
name: dashboard.name,
|
||||
containsChart: dashboard.containsChart,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
@@ -71,7 +79,7 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAddToDashboardDialogOpen, workspaceId]);
|
||||
}, [isAddToDashboardDialogOpen, workspaceId, chart.id]);
|
||||
|
||||
const handleDeleteChart = async () => {
|
||||
setIsDeleting(true);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesia
|
||||
import {
|
||||
CHART_BRAND_DARK,
|
||||
CHART_MEASURE_COLORS,
|
||||
formatCellValue,
|
||||
formatXAxisTick,
|
||||
preparePieData,
|
||||
} from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
@@ -20,6 +21,19 @@ const formatPieLabel = ({ name, percent }: { name: string; percent?: number }):
|
||||
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
|
||||
};
|
||||
|
||||
const PieTooltipRow = ({ value, name }: Readonly<{ value: unknown; name: string }>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{formatCellValue(value)} {formatCubeColumnHeader(name, t)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const pieTooltipFormatter = (value: unknown, name: string | number) => (
|
||||
<PieTooltipRow value={value} name={String(name)} />
|
||||
);
|
||||
|
||||
interface ChartRendererProps {
|
||||
chartType: TChartType;
|
||||
data: TChartDataRow[];
|
||||
@@ -165,13 +179,7 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
|
||||
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
|
||||
})}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, name) => [String(value), formatCubeColumnHeader(String(name), t)]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent formatter={pieTooltipFormatter} />} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
@@ -79,6 +80,8 @@ export function CreateChartView({
|
||||
});
|
||||
|
||||
const chartPreviewRef = useRef<HTMLDivElement>(null);
|
||||
const CREATE_CHART_FORM_ID = "create-chart-form";
|
||||
const [chartNameError, setChartNameError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartData) {
|
||||
@@ -136,17 +139,38 @@ export function CreateChartView({
|
||||
<div className="grid gap-4">
|
||||
{hasSelectedDirectory ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<form
|
||||
id={CREATE_CHART_FORM_ID}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setChartNameError(null);
|
||||
return handleSaveChart();
|
||||
}}
|
||||
className="space-y-2">
|
||||
<Label htmlFor="create-chart-name" className={cn(chartNameError && "text-red-500")}>
|
||||
{t("workspace.analysis.charts.chart_name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="create-chart-name"
|
||||
value={chartName}
|
||||
onChange={(event) => setChartName(event.target.value)}
|
||||
onChange={(event) => {
|
||||
if (chartNameError) setChartNameError(null);
|
||||
setChartName(event.target.value);
|
||||
}}
|
||||
onInvalid={(event) => {
|
||||
// Suppress the browser tooltip and render our inline message instead.
|
||||
event.preventDefault();
|
||||
event.currentTarget.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
event.currentTarget.focus();
|
||||
setChartNameError(t("workspace.analysis.charts.please_enter_chart_name"));
|
||||
}}
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
maxLength={255}
|
||||
required
|
||||
isInvalid={!!chartNameError}
|
||||
/>
|
||||
</div>
|
||||
{chartNameError && <p className="text-sm text-red-500">{chartNameError}</p>}
|
||||
</form>
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
@@ -212,7 +236,7 @@ export function CreateChartView({
|
||||
|
||||
{chartData && (
|
||||
<ChartDialogFooter
|
||||
onSaveClick={handleSaveChart}
|
||||
formId={CREATE_CHART_FORM_ID}
|
||||
isSaving={isSaving}
|
||||
showAddToDashboard={false}
|
||||
saveLabel={
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
duplicateDashboard,
|
||||
getDashboard,
|
||||
getDashboards,
|
||||
removeWidgetFromDashboard,
|
||||
updateDashboard,
|
||||
updateWidgetLayouts,
|
||||
} from "./lib/dashboards";
|
||||
@@ -111,15 +112,13 @@ export const updateDashboardAction = authenticatedActionClient.inputSchema(ZUpda
|
||||
const ZUpdateWidgetLayoutsAction = z.object({
|
||||
workspaceId: ZId,
|
||||
dashboardId: ZId,
|
||||
widgets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: ZId,
|
||||
layout: ZWidgetLayout,
|
||||
order: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
widgets: z.array(
|
||||
z.object({
|
||||
id: ZId,
|
||||
layout: ZWidgetLayout,
|
||||
order: z.number().int().nonnegative(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const updateWidgetLayoutsAction = authenticatedActionClient
|
||||
@@ -234,28 +233,21 @@ export const duplicateDashboardAction = authenticatedActionClient
|
||||
|
||||
const ZGetDashboardsAction = z.object({
|
||||
workspaceId: ZId,
|
||||
chartId: ZId.optional(),
|
||||
});
|
||||
|
||||
export const getDashboardsAction = authenticatedActionClient
|
||||
.inputSchema(ZGetDashboardsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetDashboardsAction>;
|
||||
}) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"read"
|
||||
);
|
||||
await checkDashboardsEnabled(organizationId);
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"read"
|
||||
);
|
||||
await checkDashboardsEnabled(organizationId);
|
||||
|
||||
return getDashboards(workspaceId);
|
||||
}
|
||||
);
|
||||
return getDashboards(workspaceId, parsedInput.chartId);
|
||||
});
|
||||
|
||||
const ZGetDashboardAction = z.object({
|
||||
workspaceId: ZId,
|
||||
@@ -325,3 +317,34 @@ export const addChartToDashboardAction = authenticatedActionClient
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZRemoveWidgetFromDashboardAction = z.object({
|
||||
workspaceId: ZId,
|
||||
dashboardId: ZId,
|
||||
widgetId: ZId,
|
||||
});
|
||||
|
||||
export const removeWidgetFromDashboardAction = authenticatedActionClient
|
||||
.inputSchema(ZRemoveWidgetFromDashboardAction)
|
||||
.action(
|
||||
withAuditLogging("deleted", "dashboardWidget", async ({ ctx, parsedInput }) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"readWrite"
|
||||
);
|
||||
await checkDashboardsEnabled(organizationId);
|
||||
|
||||
const widget = await removeWidgetFromDashboard(
|
||||
parsedInput.dashboardId,
|
||||
workspaceId,
|
||||
parsedInput.widgetId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
|
||||
ctx.auditLoggingCtx.oldObject = widget;
|
||||
return { success: true };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -89,10 +89,19 @@ export function AddExistingChartsDialog({
|
||||
const handleAdd = async () => {
|
||||
if (selectedChartIds.length === 0) return;
|
||||
|
||||
const chartIdsToAdd = Array.from(new Set(selectedChartIds)).filter(
|
||||
(chartId) => !existingChartIdsRef.current.includes(chartId)
|
||||
);
|
||||
|
||||
if (chartIdsToAdd.length === 0) {
|
||||
setSelectedChartIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
selectedChartIds.map((chartId) => addChartToDashboardAction({ workspaceId, chartId, dashboardId }))
|
||||
chartIdsToAdd.map((chartId) => addChartToDashboardAction({ workspaceId, chartId, dashboardId }))
|
||||
);
|
||||
|
||||
const fulfilled = results.filter(
|
||||
@@ -112,7 +121,7 @@ export function AddExistingChartsDialog({
|
||||
} else {
|
||||
toast.success(
|
||||
t("workspace.analysis.dashboards.charts_added_to_dashboard", {
|
||||
count: selectedChartIds.length,
|
||||
count: chartIdsToAdd.length,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -134,7 +143,7 @@ export function AddExistingChartsDialog({
|
||||
<DialogTitle>{t("common.add_charts")}</DialogTitle>
|
||||
<DialogDescription>{t("common.add_existing_chart_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<DialogBody className="p-1">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center rounded-md border px-3 py-2">
|
||||
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
|
||||
|
||||
@@ -72,7 +72,7 @@ export const DashboardControlBar = ({
|
||||
|
||||
const editModeActions = [
|
||||
{
|
||||
icon: CheckIcon,
|
||||
icon: isSaving ? null : CheckIcon,
|
||||
tooltip: hasChanges ? t("common.save") : t("common.no_changes"),
|
||||
onClick: onSave,
|
||||
isVisible: true,
|
||||
|
||||
@@ -17,10 +17,15 @@ import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/das
|
||||
import { DashboardWidgetData } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-data";
|
||||
import { DashboardWidgetSkeleton } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-skeleton";
|
||||
import type { TChartDataRow, TDashboardDetail, TDashboardWidget } from "@/modules/ee/analysis/types/analysis";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { updateDashboardAction, updateWidgetLayoutsAction } from "../actions";
|
||||
import {
|
||||
removeWidgetFromDashboardAction,
|
||||
updateDashboardAction,
|
||||
updateWidgetLayoutsAction,
|
||||
} from "../actions";
|
||||
import type { TDashboardWidgetError } from "../lib/widget-errors";
|
||||
|
||||
const ROW_HEIGHT = 80;
|
||||
@@ -163,6 +168,8 @@ export function DashboardDetailClient({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editingChartId, setEditingChartId] = useState<string | null>(null);
|
||||
const [widgetIdToRemove, setWidgetIdToRemove] = useState<string | null>(null);
|
||||
const [isRemovingWidget, setIsRemovingWidget] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [name, setName] = useState(dashboard.name);
|
||||
@@ -207,17 +214,36 @@ export function DashboardDetailClient({
|
||||
|
||||
const handleRemoveWidgetFromMenu = useCallback(
|
||||
(widgetId: string) => {
|
||||
if (!isEditing) {
|
||||
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
|
||||
setIsEditing(true);
|
||||
if (isEditing) {
|
||||
handleRemoveWidget(widgetId);
|
||||
return;
|
||||
}
|
||||
|
||||
handleRemoveWidget(widgetId);
|
||||
setWidgetIdToRemove(widgetId);
|
||||
},
|
||||
[dashboard.widgets, handleRemoveWidget, isEditing]
|
||||
[handleRemoveWidget, isEditing]
|
||||
);
|
||||
|
||||
const handleConfirmRemoveWidget = useCallback(async () => {
|
||||
if (!widgetIdToRemove) return;
|
||||
setIsRemovingWidget(true);
|
||||
try {
|
||||
const result = await removeWidgetFromDashboardAction({
|
||||
workspaceId,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId: widgetIdToRemove,
|
||||
});
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("workspace.analysis.dashboards.chart_removed"));
|
||||
setWidgetIdToRemove(null);
|
||||
startTransition(() => router.refresh());
|
||||
} finally {
|
||||
setIsRemovingWidget(false);
|
||||
}
|
||||
}, [widgetIdToRemove, workspaceId, dashboard.id, router, t, startTransition]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setName(dashboard.name);
|
||||
setDraftWidgets(null);
|
||||
@@ -373,6 +399,17 @@ export function DashboardDetailClient({
|
||||
aiUnavailableReason={aiUnavailableReason}
|
||||
/>
|
||||
)}
|
||||
{!isReadOnly && (
|
||||
<DeleteDialog
|
||||
open={widgetIdToRemove !== null}
|
||||
setOpen={(open) => {
|
||||
if (!open) setWidgetIdToRemove(null);
|
||||
}}
|
||||
deleteWhat={t("common.chart")}
|
||||
onDelete={handleConfirmRemoveWidget}
|
||||
isDeleting={isRemovingWidget}
|
||||
/>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@ var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
|
||||
var mockTxWidget: {
|
||||
// NOSONAR / test code
|
||||
aggregate: ReturnType<typeof vi.fn>;
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
@@ -29,9 +31,11 @@ vi.mock("@formbricks/database", () => {
|
||||
const txChart = { findFirst: vi.fn() };
|
||||
const txWidget = {
|
||||
aggregate: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
mockTxDashboard = txDash;
|
||||
@@ -44,6 +48,7 @@ vi.mock("@formbricks/database", () => {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
dashboardWidget: txWidget,
|
||||
$transaction: vi.fn((cb: any) => cb({ dashboard: txDash, chart: txChart, dashboardWidget: txWidget })),
|
||||
},
|
||||
};
|
||||
@@ -93,6 +98,7 @@ const makePrismaError = (code: string) =>
|
||||
describe("Dashboard Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTxWidget.findFirst.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("createDashboard", () => {
|
||||
@@ -459,6 +465,40 @@ describe("Dashboard Service", () => {
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns containsChart per dashboard when chartId is provided", async () => {
|
||||
const dashboards = [
|
||||
{
|
||||
...mockDashboard,
|
||||
creator: { name: "Alice" },
|
||||
_count: { widgets: 3 },
|
||||
widgets: [{ id: "widget-1" }],
|
||||
},
|
||||
{
|
||||
...mockDashboard,
|
||||
id: "dash-2",
|
||||
name: "Dashboard 2",
|
||||
creator: null,
|
||||
_count: { widgets: 0 },
|
||||
widgets: [],
|
||||
},
|
||||
];
|
||||
vi.mocked(prisma.dashboard.findMany).mockResolvedValue(dashboards as any);
|
||||
const { getDashboards } = await import("./dashboards");
|
||||
|
||||
const result = await getDashboards(mockWorkspaceId, mockChartId);
|
||||
|
||||
expect(result[0]).toMatchObject({ containsChart: true });
|
||||
expect(result[1]).toMatchObject({ containsChart: false });
|
||||
expect((result[0] as any).widgets).toBeUndefined();
|
||||
expect(prisma.dashboard.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: expect.objectContaining({
|
||||
widgets: { where: { chartId: mockChartId }, select: { id: true }, take: 1 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWidgetLayouts", () => {
|
||||
@@ -649,6 +689,26 @@ describe("Dashboard Service", () => {
|
||||
expect(mockTxWidget.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when chart is already on the dashboard", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
mockTxWidget.findFirst.mockResolvedValue({ id: "existing-widget-abc-123" });
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
layout: mockLayout,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
expect(mockTxWidget.aggregate).not.toHaveBeenCalled();
|
||||
expect(mockTxWidget.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
|
||||
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
|
||||
@@ -672,4 +732,52 @@ describe("Dashboard Service", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeWidgetFromDashboard", () => {
|
||||
const mockWidgetId = "widget-abc-123";
|
||||
|
||||
test("deletes a widget that belongs to the dashboard", async () => {
|
||||
const mockWidget = { id: mockWidgetId, dashboardId: mockDashboardId, chartId: mockChartId };
|
||||
mockTxWidget.findFirst.mockResolvedValue(mockWidget);
|
||||
mockTxWidget.delete.mockResolvedValue(mockWidget);
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId);
|
||||
|
||||
expect(result).toEqual(mockWidget);
|
||||
expect(mockTxWidget.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockWidgetId, dashboard: { id: mockDashboardId, workspaceId: mockWorkspaceId } },
|
||||
});
|
||||
expect(mockTxWidget.delete).toHaveBeenCalledWith({ where: { id: mockWidgetId } });
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when the widget is not on the dashboard", async () => {
|
||||
mockTxWidget.findFirst.mockResolvedValue(null);
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)
|
||||
).rejects.toMatchObject({ name: "ResourceNotFoundError", resourceType: "DashboardWidget" });
|
||||
expect(mockTxWidget.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("wraps Prisma errors in DatabaseError", async () => {
|
||||
mockTxWidget.findFirst.mockRejectedValue(makePrismaError("P9999"));
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)
|
||||
).rejects.toMatchObject({ name: "DatabaseError" });
|
||||
});
|
||||
|
||||
test("rethrows unknown errors", async () => {
|
||||
const error = new Error("boom");
|
||||
mockTxWidget.findFirst.mockRejectedValue(error);
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)).rejects.toBe(
|
||||
error
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,19 +158,34 @@ export const getDashboard = async (dashboardId: string, workspaceId: string) =>
|
||||
}
|
||||
};
|
||||
|
||||
export const getDashboards = async (workspaceId: string): Promise<TDashboardWithCount[]> => {
|
||||
validateInputs([workspaceId, ZId]);
|
||||
export const getDashboards = async (
|
||||
workspaceId: string,
|
||||
chartId?: string
|
||||
): Promise<TDashboardWithCount[]> => {
|
||||
validateInputs([workspaceId, ZId], [chartId, ZId.optional()]);
|
||||
|
||||
try {
|
||||
return await prisma.dashboard.findMany({
|
||||
const select = {
|
||||
...selectDashboard,
|
||||
creator: { select: { name: true } },
|
||||
_count: { select: { widgets: true } },
|
||||
...(chartId ? { widgets: { where: { chartId }, select: { id: true }, take: 1 } } : {}),
|
||||
};
|
||||
|
||||
const dashboards = await prisma.dashboard.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
...selectDashboard,
|
||||
creator: { select: { name: true } },
|
||||
_count: { select: { widgets: true } },
|
||||
},
|
||||
select,
|
||||
});
|
||||
|
||||
if (!chartId) {
|
||||
return dashboards;
|
||||
}
|
||||
|
||||
return dashboards.map(({ widgets, ...rest }) => ({
|
||||
...rest,
|
||||
containsChart: (widgets?.length ?? 0) > 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
@@ -301,6 +316,31 @@ export const updateWidgetLayouts = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const removeWidgetFromDashboard = async (
|
||||
dashboardId: string,
|
||||
workspaceId: string,
|
||||
widgetId: string
|
||||
) => {
|
||||
validateInputs([dashboardId, ZId], [workspaceId, ZId], [widgetId, ZId]);
|
||||
|
||||
try {
|
||||
const widget = await prisma.dashboardWidget.findFirst({
|
||||
where: { id: widgetId, dashboard: { id: dashboardId, workspaceId } },
|
||||
});
|
||||
|
||||
if (!widget) {
|
||||
throw new ResourceNotFoundError("DashboardWidget", widgetId);
|
||||
}
|
||||
|
||||
return await prisma.dashboardWidget.delete({ where: { id: widgetId } });
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const addChartToDashboard = async (data: TAddWidgetInput) => {
|
||||
validateInputs([data, ZAddWidgetInput]);
|
||||
|
||||
@@ -319,6 +359,18 @@ export const addChartToDashboard = async (data: TAddWidgetInput) => {
|
||||
throw new ResourceNotFoundError("Dashboard", data.dashboardId);
|
||||
}
|
||||
|
||||
const existingWidget = await tx.dashboardWidget.findFirst({
|
||||
where: {
|
||||
dashboardId: data.dashboardId,
|
||||
chartId: data.chartId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existingWidget) {
|
||||
throw new InvalidInputError("This chart is already on the dashboard");
|
||||
}
|
||||
|
||||
const [maxOrder, existingWidgets] = await Promise.all([
|
||||
tx.dashboardWidget.aggregate({
|
||||
where: { dashboardId: data.dashboardId },
|
||||
@@ -350,7 +402,7 @@ export const addChartToDashboard = async (data: TAddWidgetInput) => {
|
||||
{ isolationLevel: "Serializable" }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -52,7 +52,7 @@ ${operatorsText}
|
||||
|
||||
## Guidelines
|
||||
- Always include at least one measure. If unspecified, default to \`${CUBE_NAME}.count\`.
|
||||
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sentiment\`, \`FeedbackRecords.collectedAt\`).
|
||||
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sourceType\`, \`FeedbackRecords.collectedAt\`).
|
||||
- For time-based filtering (date range only, no time grouping): add a timeDimension with dimension \`${CUBE_NAME}.collectedAt\` and dateRange. Do NOT include granularity (default is None / filter only).
|
||||
- For time-series or trend questions (e.g. "over time", "by day", "weekly", "monthly"): add a timeDimension with dimension, granularity (hour/day/week/month/quarter/year), and dateRange.
|
||||
- Choose the most appropriate chart type: bar, line, area, pie, or big_number (for single-number queries).
|
||||
|
||||
@@ -22,13 +22,13 @@ describe("query-builder", () => {
|
||||
test("adds dimensions when present", () => {
|
||||
const config: ChartBuilderState = {
|
||||
selectedMeasures: ["FeedbackRecords.count"],
|
||||
selectedDimensions: ["FeedbackRecords.sentiment"],
|
||||
selectedDimensions: ["FeedbackRecords.userId"],
|
||||
filters: [],
|
||||
filterLogic: "and",
|
||||
timeDimension: null,
|
||||
};
|
||||
const query = buildCubeQuery(config);
|
||||
expect(query.dimensions).toEqual(["FeedbackRecords.sentiment"]);
|
||||
expect(query.dimensions).toEqual(["FeedbackRecords.userId"]);
|
||||
});
|
||||
|
||||
test("adds time dimension with string dateRange", () => {
|
||||
@@ -93,7 +93,7 @@ describe("query-builder", () => {
|
||||
selectedMeasures: ["FeedbackRecords.count"],
|
||||
selectedDimensions: [],
|
||||
filters: [
|
||||
{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ id: "f1", field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
|
||||
{ id: "f2", field: "FeedbackRecords.sourceType", operator: "set", values: null },
|
||||
],
|
||||
filterLogic: "and",
|
||||
@@ -101,7 +101,7 @@ describe("query-builder", () => {
|
||||
};
|
||||
const query = buildCubeQuery(config);
|
||||
expect(query.filters).toEqual([
|
||||
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.sourceType", operator: "set" },
|
||||
]);
|
||||
});
|
||||
@@ -110,14 +110,14 @@ describe("query-builder", () => {
|
||||
const config: ChartBuilderState = {
|
||||
selectedMeasures: ["FeedbackRecords.count"],
|
||||
selectedDimensions: [],
|
||||
filters: [{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
|
||||
filters: [{ id: "f1", field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
|
||||
filterLogic: "or",
|
||||
timeDimension: null,
|
||||
};
|
||||
const query = buildCubeQuery(config);
|
||||
expect(query.filters).toEqual([
|
||||
{
|
||||
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
|
||||
or: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -136,12 +136,12 @@ describe("query-builder", () => {
|
||||
test("parses AND member filters", () => {
|
||||
const query = {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
|
||||
filters: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
|
||||
};
|
||||
const state = parseQueryToState(query);
|
||||
expect(state.filterLogic).toBe("and");
|
||||
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
|
||||
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -150,14 +150,14 @@ describe("query-builder", () => {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [
|
||||
{
|
||||
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
|
||||
or: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const state = parseQueryToState(query);
|
||||
expect(state.filterLogic).toBe("or");
|
||||
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
|
||||
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -202,7 +202,7 @@ describe("query-builder", () => {
|
||||
test("buildCubeQuery then parseQueryToState restores state", () => {
|
||||
const config: ChartBuilderState = {
|
||||
selectedMeasures: ["FeedbackRecords.count"],
|
||||
selectedDimensions: ["FeedbackRecords.sentiment"],
|
||||
selectedDimensions: ["FeedbackRecords.userId"],
|
||||
filters: [{ id: "f1", field: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
|
||||
filterLogic: "and",
|
||||
timeDimension: {
|
||||
|
||||
@@ -64,10 +64,13 @@ export function buildCubeQuery(config: ChartBuilderState): TChartQuery {
|
||||
timeDim.dateRange = config.timeDimension.dateRange;
|
||||
} else if (Array.isArray(config.timeDimension.dateRange)) {
|
||||
const [startDate, endDate] = config.timeDimension.dateRange;
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const formatDate = (date: Date | string) => {
|
||||
// dateRange round-trips through JSON (saved chart → parseQueryToState), so the array
|
||||
// elements may already be ISO strings — coerce before formatting.
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
|
||||
@@ -137,7 +140,12 @@ export function parseQueryToState(query: TChartQuery): Partial<ChartBuilderState
|
||||
config.granularity = timeDim.granularity;
|
||||
}
|
||||
if (timeDim.dateRange) {
|
||||
config.dateRange = timeDim.dateRange as TimeDimensionConfig["dateRange"];
|
||||
if (typeof timeDim.dateRange === "string") {
|
||||
config.dateRange = timeDim.dateRange;
|
||||
} else if (Array.isArray(timeDim.dateRange) && timeDim.dateRange.length === 2) {
|
||||
// Stored as [isoString, isoString]; lift back into Date objects for the date-picker UI.
|
||||
config.dateRange = [new Date(timeDim.dateRange[0]), new Date(timeDim.dateRange[1])];
|
||||
}
|
||||
}
|
||||
state.timeDimension = config;
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ describe("schema-definition", () => {
|
||||
|
||||
describe("getFieldById", () => {
|
||||
test("returns dimension by id", () => {
|
||||
const field = getFieldById("FeedbackRecords.sentiment");
|
||||
const field = getFieldById("FeedbackRecords.sourceType");
|
||||
expect(field).toBeDefined();
|
||||
expect(field?.label).toBe("Sentiment");
|
||||
expect(field?.label).toBe("Source Type");
|
||||
expect(field?.type).toBe("string");
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("schema-definition", () => {
|
||||
});
|
||||
|
||||
test("returns field label for known dimension/measure", () => {
|
||||
expect(formatCubeColumnHeader("FeedbackRecords.sentiment")).toBe("Sentiment");
|
||||
expect(formatCubeColumnHeader("FeedbackRecords.sourceType")).toBe("Source Type");
|
||||
expect(formatCubeColumnHeader("FeedbackRecords.count")).toBe("Count");
|
||||
});
|
||||
|
||||
@@ -74,5 +74,25 @@ describe("schema-definition", () => {
|
||||
expect(FEEDBACK_FIELDS.dimensions.length).toBeGreaterThan(0);
|
||||
expect(FEEDBACK_FIELDS.measures.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("exposes CSAT, CES, NPS and universal measures", () => {
|
||||
const ids = FEEDBACK_FIELDS.measures.map((m) => m.id);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining([
|
||||
"FeedbackRecords.count",
|
||||
"FeedbackRecords.uniqueRespondents",
|
||||
"FeedbackRecords.uniqueResponses",
|
||||
"FeedbackRecords.npsScore",
|
||||
"FeedbackRecords.npsAverage",
|
||||
"FeedbackRecords.csatScore",
|
||||
"FeedbackRecords.csatAverage",
|
||||
"FeedbackRecords.csatSatisfiedCount",
|
||||
"FeedbackRecords.csatCount",
|
||||
"FeedbackRecords.cesAverage",
|
||||
"FeedbackRecords.cesCount",
|
||||
])
|
||||
);
|
||||
expect(ids).not.toContain("FeedbackRecords.averageScore");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TFunction } from "i18next";
|
||||
export interface FieldDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "string" | "number" | "time";
|
||||
type: "string" | "number" | "time" | "boolean";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -20,12 +20,6 @@ export interface MeasureDefinition {
|
||||
|
||||
export const FEEDBACK_FIELDS = {
|
||||
dimensions: [
|
||||
{
|
||||
id: "FeedbackRecords.sentiment",
|
||||
label: "Sentiment",
|
||||
type: "string",
|
||||
description: "Sentiment extracted from feedback",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.sourceType",
|
||||
label: "Source Type",
|
||||
@@ -45,10 +39,22 @@ export const FEEDBACK_FIELDS = {
|
||||
description: "Type of feedback field (e.g., nps, text, rating)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.emotion",
|
||||
label: "Emotion",
|
||||
id: "FeedbackRecords.fieldLabel",
|
||||
label: "Question",
|
||||
type: "string",
|
||||
description: "Emotion extracted from metadata JSONB field",
|
||||
description: "Human-readable label of the question/field",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.fieldGroupLabel",
|
||||
label: "Question Group",
|
||||
type: "string",
|
||||
description: "Label of the parent composite question for matrix/ranking rows",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.language",
|
||||
label: "Language",
|
||||
type: "string",
|
||||
description: 'Response language code (e.g., "en", "de")',
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.userId",
|
||||
@@ -63,10 +69,30 @@ export const FEEDBACK_FIELDS = {
|
||||
description: "Unique identifier linking related feedback records",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.npsValue",
|
||||
label: "NPS Value",
|
||||
id: "FeedbackRecords.valueNumber",
|
||||
label: "Value (Number)",
|
||||
type: "number",
|
||||
description: "Raw NPS score value (0-10)",
|
||||
description:
|
||||
"Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, number). Pair with a fieldType filter to keep scales consistent.",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.valueText",
|
||||
label: "Value (Text)",
|
||||
type: "string",
|
||||
description:
|
||||
"Text answer value (open text, or the label of a multiple-choice/categorical answer). Pair with a fieldType filter to keep types consistent.",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.valueBoolean",
|
||||
label: "Value (Boolean)",
|
||||
type: "boolean",
|
||||
description: "Boolean answer value (yes/no). Pair with a fieldType filter.",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.valueDate",
|
||||
label: "Value (Date)",
|
||||
type: "time",
|
||||
description: "Date answer value. Pair with a fieldType filter.",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.collectedAt",
|
||||
@@ -75,10 +101,16 @@ export const FEEDBACK_FIELDS = {
|
||||
description: "Timestamp when the feedback was collected",
|
||||
},
|
||||
{
|
||||
id: "TopicsUnnested.topic",
|
||||
label: "Topic",
|
||||
type: "string",
|
||||
description: "Individual topic from the topics array",
|
||||
id: "FeedbackRecords.createdAt",
|
||||
label: "Created At",
|
||||
type: "time",
|
||||
description: "Timestamp when the feedback record was created in Hub",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.updatedAt",
|
||||
label: "Updated At",
|
||||
type: "time",
|
||||
description: "Timestamp when the feedback record was last updated in Hub",
|
||||
},
|
||||
] as FieldDefinition[],
|
||||
measures: [
|
||||
@@ -89,38 +121,106 @@ export const FEEDBACK_FIELDS = {
|
||||
description: "Total number of feedback responses",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.promoterCount",
|
||||
label: "Promoter Count",
|
||||
type: "count",
|
||||
description: "Number of promoters (NPS score 9-10)",
|
||||
id: "FeedbackRecords.uniqueRespondents",
|
||||
label: "Unique Respondents",
|
||||
type: "number",
|
||||
description: "Number of unique users who provided feedback",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.detractorCount",
|
||||
label: "Detractor Count",
|
||||
type: "count",
|
||||
description: "Number of detractors (NPS score 0-6)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.passiveCount",
|
||||
label: "Passive Count",
|
||||
type: "count",
|
||||
description: "Number of passives (NPS score 7-8)",
|
||||
id: "FeedbackRecords.uniqueResponses",
|
||||
label: "Unique Responses",
|
||||
type: "number",
|
||||
description: "Number of unique survey submissions",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.npsScore",
|
||||
label: "NPS Score",
|
||||
type: "number",
|
||||
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
|
||||
description: "Net Promoter Score: ((Promoters - Detractors) / Total NPS responses) * 100",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.averageScore",
|
||||
label: "Average Score",
|
||||
id: "FeedbackRecords.npsAverage",
|
||||
label: "NPS Average",
|
||||
type: "number",
|
||||
description: "Average NPS score",
|
||||
description: "Average NPS rating (0-10)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.promoterCount",
|
||||
label: "Promoter Count",
|
||||
type: "count",
|
||||
description: "Number of NPS promoters (score 9-10)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.passiveCount",
|
||||
label: "Passive Count",
|
||||
type: "count",
|
||||
description: "Number of NPS passives (score 7-8)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.detractorCount",
|
||||
label: "Detractor Count",
|
||||
type: "count",
|
||||
description: "Number of NPS detractors (score 0-6)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatScore",
|
||||
label: "CSAT Score",
|
||||
type: "number",
|
||||
description: "CSAT Score: % of CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatAverage",
|
||||
label: "CSAT Average",
|
||||
type: "number",
|
||||
description: "Average CSAT rating (1-5)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatSatisfiedCount",
|
||||
label: "CSAT Satisfied Count",
|
||||
type: "count",
|
||||
description: "Number of satisfied CSAT responses (top-2-box on the 1-5 scale)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatDissatisfiedCount",
|
||||
label: "CSAT Dissatisfied Count",
|
||||
type: "count",
|
||||
description: "Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatNeutralCount",
|
||||
label: "CSAT Neutral Count",
|
||||
type: "count",
|
||||
description: "Number of neutral CSAT responses (middle box on the 1-5 scale)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatCount",
|
||||
label: "CSAT Count",
|
||||
type: "count",
|
||||
description: "Number of CSAT responses",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.cesAverage",
|
||||
label: "CES Average",
|
||||
type: "number",
|
||||
description: "Average CES rating (scale is 1-5 or 1-7 depending on the question)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.cesCount",
|
||||
label: "CES Count",
|
||||
type: "count",
|
||||
description: "Number of CES responses",
|
||||
},
|
||||
] as MeasureDefinition[],
|
||||
};
|
||||
|
||||
export const FEEDBACK_MEASURE_IDS: string[] = FEEDBACK_FIELDS.measures.map((m) => m.id);
|
||||
|
||||
export const FEEDBACK_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions.map((d) => d.id);
|
||||
|
||||
export const FEEDBACK_TIME_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions
|
||||
.filter((d) => d.type === "time")
|
||||
.map((d) => d.id);
|
||||
|
||||
export type FilterOperator =
|
||||
| "equals"
|
||||
| "notEquals"
|
||||
@@ -137,6 +237,7 @@ export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
|
||||
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
|
||||
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||
boolean: ["equals", "notEquals", "set", "notSet"],
|
||||
};
|
||||
|
||||
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
|
||||
@@ -166,7 +267,7 @@ export const DATE_PRESETS = [
|
||||
/**
|
||||
* Get filter operators for a given field type.
|
||||
*/
|
||||
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
|
||||
export function getFilterOperatorsForType(type: "string" | "number" | "time" | "boolean"): FilterOperator[] {
|
||||
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
|
||||
}
|
||||
|
||||
@@ -184,22 +285,39 @@ export function getFieldById(id: string): FieldDefinition | MeasureDefinition |
|
||||
*/
|
||||
export function getTranslatedFieldLabel(id: string, t: TFunction): string {
|
||||
const labels: Record<string, string> = {
|
||||
"FeedbackRecords.sentiment": t("workspace.analysis.charts.field_label_sentiment"),
|
||||
"FeedbackRecords.sourceType": t("workspace.analysis.charts.field_label_source_type"),
|
||||
"FeedbackRecords.sourceName": t("workspace.analysis.charts.field_label_source_name"),
|
||||
"FeedbackRecords.fieldType": t("workspace.analysis.charts.field_label_field_type"),
|
||||
"FeedbackRecords.emotion": t("workspace.analysis.charts.field_label_emotion"),
|
||||
"FeedbackRecords.fieldLabel": t("workspace.analysis.charts.field_label_question"),
|
||||
"FeedbackRecords.fieldGroupLabel": t("workspace.analysis.charts.field_label_question_group"),
|
||||
"FeedbackRecords.language": t("workspace.analysis.charts.field_label_language"),
|
||||
"FeedbackRecords.userId": t("workspace.analysis.charts.field_label_user_identifier"),
|
||||
"FeedbackRecords.responseId": t("workspace.analysis.charts.field_label_response_id"),
|
||||
"FeedbackRecords.npsValue": t("workspace.analysis.charts.field_label_nps_value"),
|
||||
"FeedbackRecords.valueNumber": t("workspace.analysis.charts.field_label_value_number"),
|
||||
"FeedbackRecords.valueText": t("workspace.analysis.charts.field_label_value_text"),
|
||||
"FeedbackRecords.valueBoolean": t("workspace.analysis.charts.field_label_value_boolean"),
|
||||
"FeedbackRecords.valueDate": t("workspace.analysis.charts.field_label_value_date"),
|
||||
"FeedbackRecords.collectedAt": t("workspace.analysis.charts.field_label_collected_at"),
|
||||
"TopicsUnnested.topic": t("workspace.analysis.charts.field_label_topic"),
|
||||
"FeedbackRecords.createdAt": t("workspace.analysis.charts.field_label_created_at"),
|
||||
"FeedbackRecords.updatedAt": t("workspace.analysis.charts.field_label_updated_at"),
|
||||
"FeedbackRecords.count": t("workspace.analysis.charts.field_label_count"),
|
||||
"FeedbackRecords.promoterCount": t("workspace.analysis.charts.field_label_promoter_count"),
|
||||
"FeedbackRecords.detractorCount": t("workspace.analysis.charts.field_label_detractor_count"),
|
||||
"FeedbackRecords.passiveCount": t("workspace.analysis.charts.field_label_passive_count"),
|
||||
"FeedbackRecords.uniqueRespondents": t("workspace.analysis.charts.field_label_unique_respondents"),
|
||||
"FeedbackRecords.uniqueResponses": t("workspace.analysis.charts.field_label_unique_responses"),
|
||||
"FeedbackRecords.npsScore": t("workspace.analysis.charts.field_label_nps_score"),
|
||||
"FeedbackRecords.averageScore": t("workspace.analysis.charts.field_label_average_score"),
|
||||
"FeedbackRecords.npsAverage": t("workspace.analysis.charts.field_label_nps_average"),
|
||||
"FeedbackRecords.promoterCount": t("workspace.analysis.charts.field_label_promoter_count"),
|
||||
"FeedbackRecords.passiveCount": t("workspace.analysis.charts.field_label_passive_count"),
|
||||
"FeedbackRecords.detractorCount": t("workspace.analysis.charts.field_label_detractor_count"),
|
||||
"FeedbackRecords.csatScore": t("workspace.analysis.charts.field_label_csat_score"),
|
||||
"FeedbackRecords.csatAverage": t("workspace.analysis.charts.field_label_csat_average"),
|
||||
"FeedbackRecords.csatSatisfiedCount": t("workspace.analysis.charts.field_label_csat_satisfied_count"),
|
||||
"FeedbackRecords.csatDissatisfiedCount": t(
|
||||
"workspace.analysis.charts.field_label_csat_dissatisfied_count"
|
||||
),
|
||||
"FeedbackRecords.csatNeutralCount": t("workspace.analysis.charts.field_label_csat_neutral_count"),
|
||||
"FeedbackRecords.csatCount": t("workspace.analysis.charts.field_label_csat_count"),
|
||||
"FeedbackRecords.cesAverage": t("workspace.analysis.charts.field_label_ces_average"),
|
||||
"FeedbackRecords.cesCount": t("workspace.analysis.charts.field_label_ces_count"),
|
||||
};
|
||||
return labels[id] ?? getFieldById(id)?.label ?? id;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export type TDashboard = {
|
||||
export type TDashboardWithCount = TDashboard & {
|
||||
creator: { name: string } | null;
|
||||
_count: { widgets: number };
|
||||
containsChart?: boolean;
|
||||
};
|
||||
|
||||
// ── Widget input schema ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -289,6 +289,47 @@ describe("FeedbackDirectory Service", () => {
|
||||
).rejects.toThrow(new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG"));
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when a workspace is already assigned to another active directory", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId1,
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
createFeedbackDirectory(mockOrganizationId, "Conflicting", [mockWorkspaceId1])
|
||||
).rejects.toThrow(new InvalidInputError("WORKSPACE_ALREADY_ASSIGNED_TO_DIFFERENT_DIRECTORY"));
|
||||
|
||||
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: { in: [mockWorkspaceId1] },
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
expect(prisma.feedbackDirectory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows creation when workspace is only assigned to archived directory", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.feedbackDirectory.create).mockResolvedValueOnce({
|
||||
id: mockDirectoryId,
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackDirectory(mockOrganizationId, "ArchivedOnly", [mockWorkspaceId1]);
|
||||
|
||||
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: { in: [mockWorkspaceId1] },
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
|
||||
expect(prisma.feedbackDirectory.create).toHaveBeenCalled();
|
||||
expect(result).toBe(mockDirectoryId);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
|
||||
@@ -279,6 +279,7 @@ export const createFeedbackDirectory = async (
|
||||
if (count !== workspaceIds.length) {
|
||||
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
|
||||
}
|
||||
await assertWorkspacesNotAssignedElsewhere(undefined, workspaceIds);
|
||||
}
|
||||
|
||||
const directory = await prisma.feedbackDirectory.create({
|
||||
@@ -440,9 +441,12 @@ const pauseConnectorsInWorkspaces = async (
|
||||
* Enforces the single-active-FRD-per-workspace invariant. The client UI prevents
|
||||
* assigning a workspace to multiple active directories, but the server must also
|
||||
* reject such payloads to keep this guarantee under direct API access.
|
||||
*
|
||||
* Pass `directoryId` when updating an existing directory to exclude it from the
|
||||
* conflict check. Omit it on create — every active directory is a conflict.
|
||||
*/
|
||||
const assertWorkspacesNotAssignedElsewhere = async (
|
||||
directoryId: string,
|
||||
directoryId: string | undefined,
|
||||
workspaceIds: string[]
|
||||
): Promise<void> => {
|
||||
if (workspaceIds.length === 0) return;
|
||||
@@ -450,7 +454,7 @@ const assertWorkspacesNotAssignedElsewhere = async (
|
||||
const conflicting = await prisma.feedbackDirectoryWorkspace.findFirst({
|
||||
where: {
|
||||
workspaceId: { in: workspaceIds },
|
||||
feedbackDirectoryId: { not: directoryId },
|
||||
...(directoryId === undefined ? {} : { feedbackDirectoryId: { not: directoryId } }),
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { FeedbackDirectoryView } from "@/modules/ee/feedback-directory/components/feedback-directory-view";
|
||||
import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
@@ -16,9 +17,12 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
|
||||
const { isOwner, isManager } = getAccessFlags(currentUserMembership.role);
|
||||
|
||||
const isFeedbackDirectoriesAllowed = await getIsFeedbackDirectoriesEnabled(organization.id);
|
||||
const pageTitle = t("workspace.settings.feedback_directories.title");
|
||||
|
||||
if (!isFeedbackDirectoriesAllowed) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} />
|
||||
<div className="flex items-center justify-center">
|
||||
<UpgradePrompt
|
||||
title={t("workspace.settings.feedback_directories.upgrade_prompt_title")}
|
||||
@@ -47,6 +51,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
|
||||
if (!isOwner && !isManager) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} />
|
||||
<p className="text-sm text-slate-500">{t("workspace.settings.feedback_directories.no_access")}</p>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
@@ -54,6 +59,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} />
|
||||
<FeedbackDirectoryView organizationId={organization.id} membershipRole={currentUserMembership.role} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -178,6 +178,7 @@ export function ConnectorRowDropdown({
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
text={t("workspace.unify.delete_source_confirmation")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { TConnectorOptionId, getConnectorOptions } from "../utils";
|
||||
|
||||
interface ConnectorTypeSelectorProps {
|
||||
selectedType: TConnectorOptionId | null;
|
||||
onSelectType: (type: TConnectorOptionId) => void;
|
||||
workspaceId: string;
|
||||
surveyCount: number;
|
||||
}
|
||||
|
||||
const getOptionClassName = (
|
||||
@@ -27,43 +29,54 @@ const getOptionClassName = (
|
||||
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
|
||||
};
|
||||
|
||||
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
|
||||
export function ConnectorTypeSelector({
|
||||
selectedType,
|
||||
onSelectType,
|
||||
workspaceId,
|
||||
surveyCount,
|
||||
}: Readonly<ConnectorTypeSelectorProps>) {
|
||||
const { t } = useTranslation();
|
||||
const connectorOptions = getConnectorOptions(t);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{connectorOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => onSelectType(option.id)}
|
||||
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
|
||||
selectedType,
|
||||
option.id,
|
||||
option.disabled
|
||||
)}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
|
||||
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-3 h-4 w-4 rounded-full border-2 ${
|
||||
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
|
||||
}`}>
|
||||
{selectedType === option.id && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white" />
|
||||
{connectorOptions.map((option) => {
|
||||
const showNoSurveysAlert =
|
||||
surveyCount === 0 && option.id === "formbricks_survey" && selectedType === "formbricks_survey";
|
||||
return (
|
||||
<div key={option.id} className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => onSelectType(option.id)}
|
||||
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
|
||||
selectedType,
|
||||
option.id,
|
||||
option.disabled
|
||||
)}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
|
||||
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`ml-3 h-4 w-4 rounded-full border-2 ${
|
||||
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
|
||||
}`}>
|
||||
{selectedType === option.id && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{showNoSurveysAlert && <NoFormbricksSurveysAlert workspaceId={workspaceId} />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Alert variant="outbound" size="small">
|
||||
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
|
||||
@@ -80,3 +93,20 @@ export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<C
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NoFormbricksSurveysAlert = ({ workspaceId }: Readonly<{ workspaceId: string }>) => {
|
||||
return (
|
||||
<Alert variant="info" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
<Trans
|
||||
i18nKey="workspace.unify.no_formbricks_surveys_available_description"
|
||||
components={{
|
||||
surveyLink: (
|
||||
<Link href={`/workspaces/${workspaceId}/surveys/templates`} className="font-medium underline" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { TFieldMapping, TUnifySurvey } from "../types";
|
||||
import { TFieldMapping, TUnifySurvey, getTranslatedConnectorError } from "../types";
|
||||
import { ConnectorsTable } from "./connectors-table";
|
||||
import { CreateConnectorModal } from "./create-connector-modal";
|
||||
import { CsvImportModal } from "./csv-import-modal";
|
||||
@@ -28,6 +28,7 @@ interface ConnectorsSectionProps {
|
||||
initialConnectors: TConnectorWithMappings[];
|
||||
initialSurveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function ConnectorsSection({
|
||||
@@ -35,6 +36,7 @@ export function ConnectorsSection({
|
||||
initialConnectors,
|
||||
initialSurveys,
|
||||
directories,
|
||||
isReadOnly,
|
||||
}: Readonly<ConnectorsSectionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -78,7 +80,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -93,7 +95,7 @@ export function ConnectorsSection({
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => {
|
||||
}): Promise<boolean> => {
|
||||
const result = await updateConnectorWithMappingsAction({
|
||||
connectorId: data.connectorId,
|
||||
workspaceId: workspaceId,
|
||||
@@ -111,19 +113,20 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_updated_successfully"));
|
||||
router.refresh();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
|
||||
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,7 +141,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,7 +158,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,11 +173,15 @@ export function ConnectorsSection({
|
||||
<SettingsCard
|
||||
title={t("workspace.unify.feedback_sources")}
|
||||
description={t("workspace.unify.feedback_sources_settings_description")}
|
||||
buttonInfo={{
|
||||
text: t("workspace.unify.add_source"),
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
variant: "default",
|
||||
}}>
|
||||
buttonInfo={
|
||||
isReadOnly
|
||||
? undefined
|
||||
: {
|
||||
text: t("workspace.unify.add_source"),
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
variant: "default",
|
||||
}
|
||||
}>
|
||||
<ConnectorsTable
|
||||
connectors={initialConnectors}
|
||||
onConnectorClick={setEditingConnector}
|
||||
@@ -183,15 +190,18 @@ export function ConnectorsSection({
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={handleDeleteConnector}
|
||||
isLoading={false}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
{directories.length > 0 && (
|
||||
<Alert size="small" className="mt-4">
|
||||
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
{!isReadOnly && (
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsCard>
|
||||
@@ -208,6 +218,7 @@ export function ConnectorsSection({
|
||||
|
||||
<EditConnectorModal
|
||||
connector={editingConnector}
|
||||
isReadOnly={isReadOnly}
|
||||
open={editingConnector !== null}
|
||||
onOpenChange={(open) => !open && setEditingConnector(null)}
|
||||
onUpdateConnector={handleUpdateConnector}
|
||||
|
||||
+15
-11
@@ -37,6 +37,7 @@ interface ConnectorsTableDataRowProps {
|
||||
onDuplicate: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
|
||||
@@ -52,10 +53,11 @@ export function ConnectorsTableDataRow({
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isReadOnly = false,
|
||||
}: Readonly<ConnectorsTableDataRowProps>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const handleRowClick = () => {
|
||||
if (connector.type === "csv" && onCsvImport) {
|
||||
if (!isReadOnly && connector.type === "csv" && onCsvImport) {
|
||||
onCsvImport();
|
||||
return;
|
||||
}
|
||||
@@ -93,10 +95,10 @@ export function ConnectorsTableDataRow({
|
||||
title={t(getConnectorTypeLabelKey(connector.type))}>
|
||||
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
|
||||
</div>
|
||||
<div className="col-span-5 flex items-center">
|
||||
<div className="col-span-4 flex items-center">
|
||||
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center justify-center sm:flex">
|
||||
<div className="col-span-2 hidden items-center justify-center sm:flex">
|
||||
<Badge
|
||||
text={getStatusLabel(connector.status, connector.type)}
|
||||
type={STATUS_BADGE_TYPE[connector.status]}
|
||||
@@ -110,14 +112,16 @@ export function ConnectorsTableDataRow({
|
||||
<span className="truncate">{connector.creatorName ?? "—"}</span>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-end pr-2">
|
||||
<ConnectorRowDropdown
|
||||
connector={connector}
|
||||
onEdit={onEdit}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
{!isReadOnly && (
|
||||
<ConnectorRowDropdown
|
||||
connector={connector}
|
||||
onEdit={onEdit}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
@@ -9,6 +9,7 @@ interface ConnectorsTableRowsContainerProps {
|
||||
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ConnectorsTableRowsContainer = ({
|
||||
@@ -18,6 +19,7 @@ export const ConnectorsTableRowsContainer = ({
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isReadOnly = false,
|
||||
}: ConnectorsTableRowsContainerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -40,6 +42,7 @@ export const ConnectorsTableRowsContainer = ({
|
||||
onDuplicate={() => onDuplicate(connector)}
|
||||
onToggleStatus={() => onToggleStatus(connector)}
|
||||
onDelete={() => onDelete(connector.id)}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ConnectorsTableProps {
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectorsTable({
|
||||
@@ -23,6 +24,7 @@ export function ConnectorsTable({
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
isReadOnly = false,
|
||||
}: Readonly<ConnectorsTableProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -30,8 +32,8 @@ export function ConnectorsTable({
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">{t("common.type")}</div>
|
||||
<div className="col-span-5">{t("common.name")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
|
||||
<div className="col-span-4">{t("common.name")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.status")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
|
||||
<div className="col-span-1" />
|
||||
@@ -48,6 +50,7 @@ export function ConnectorsTable({
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
ZFormbricksConnectorForm,
|
||||
getTranslatedConnectorError,
|
||||
} from "../types";
|
||||
import {
|
||||
TConnectorOptionId,
|
||||
@@ -339,7 +341,12 @@ export const CreateConnectorModal = ({
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
});
|
||||
|
||||
if (connectorId && values.importHistorical) {
|
||||
if (!connectorId) {
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.importHistorical) {
|
||||
await handleHistoricalImport(connectorId, values.surveyId);
|
||||
}
|
||||
|
||||
@@ -368,7 +375,12 @@ export const CreateConnectorModal = ({
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
|
||||
if (connectorId && csvParsedData.length > 0) {
|
||||
if (!connectorId) {
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (csvParsedData.length > 0) {
|
||||
await handleCsvImport(connectorId);
|
||||
}
|
||||
|
||||
@@ -414,18 +426,25 @@ export const CreateConnectorModal = ({
|
||||
<DialogDescription>{getDialogDescription(currentStep, selectedType, t)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<DialogBody>
|
||||
{currentStep === "selectType" && (
|
||||
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||
<ConnectorTypeSelector
|
||||
selectedType={selectedType}
|
||||
onSelectType={setSelectedType}
|
||||
surveyCount={surveys.length}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={formbricksForm.handleSubmit(handleCreateFormbricksConnector)}>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -435,7 +454,9 @@ export const CreateConnectorModal = ({
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -445,7 +466,7 @@ export const CreateConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -462,7 +483,9 @@ export const CreateConnectorModal = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -470,7 +493,7 @@ export const CreateConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -482,7 +505,9 @@ export const CreateConnectorModal = ({
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -569,7 +594,7 @@ export const CreateConnectorModal = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
{currentStep === "mapping" && (
|
||||
@@ -578,7 +603,9 @@ export const CreateConnectorModal = ({
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === "selectType" ? (
|
||||
<Button onClick={handleNextStep} disabled={!selectedType}>
|
||||
<Button
|
||||
onClick={handleNextStep}
|
||||
disabled={!selectedType || (selectedType === "formbricks_survey" && surveys.length === 0)}>
|
||||
{getNextStepButtonLabel(selectedType, t)}
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
ZFormbricksConnectorForm,
|
||||
getTranslatedConnectorError,
|
||||
} from "../types";
|
||||
import {
|
||||
areAllRequiredFieldsMapped,
|
||||
@@ -59,9 +60,10 @@ interface EditConnectorModalProps {
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<void>;
|
||||
}) => Promise<boolean>;
|
||||
surveys: TUnifySurvey[];
|
||||
onOpenCsvImport?: () => void;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export const EditConnectorModal = ({
|
||||
@@ -71,6 +73,7 @@ export const EditConnectorModal = ({
|
||||
onUpdateConnector,
|
||||
surveys,
|
||||
onOpenCsvImport,
|
||||
isReadOnly = false,
|
||||
}: EditConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
@@ -174,7 +177,7 @@ export const EditConnectorModal = ({
|
||||
const handleUpdateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
|
||||
if (connector?.type !== "formbricks_survey") return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
const success = await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: values.sourceName.trim(),
|
||||
@@ -182,13 +185,15 @@ export const EditConnectorModal = ({
|
||||
fieldMappings: undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
if (success) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCsvConnector = async () => {
|
||||
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
const success = await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: csvConnectorName.trim(),
|
||||
@@ -196,7 +201,9 @@ export const EditConnectorModal = ({
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
if (success) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormbricksQuestionToggle = (questionId: string) => {
|
||||
@@ -239,11 +246,13 @@ export const EditConnectorModal = ({
|
||||
<div className="space-y-4 py-4">
|
||||
{connector.type === "formbricks_survey" ? (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={formbricksForm.handleSubmit(handleUpdateFormbricksConnector)}>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -251,9 +260,12 @@ export const EditConnectorModal = ({
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -261,7 +273,7 @@ export const EditConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -281,7 +293,9 @@ export const EditConnectorModal = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -289,19 +303,21 @@ export const EditConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<fieldset className={isReadOnly ? "opacity-70" : undefined} disabled={isReadOnly}>
|
||||
<FormbricksQuestionList
|
||||
survey={selectedSurvey}
|
||||
selectedQuestionIds={selectedQuestionIds}
|
||||
onQuestionToggle={handleFormbricksQuestionToggle}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -328,41 +344,54 @@ export const EditConnectorModal = ({
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<fieldset
|
||||
disabled={isReadOnly}
|
||||
className={`max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4 ${
|
||||
isReadOnly ? "opacity-70" : ""
|
||||
}`}>
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
connectorType={connector.type}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{connector.type === "csv" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenCsvImport?.();
|
||||
}}>
|
||||
{t("workspace.unify.import_feedback")}
|
||||
{isReadOnly ? (
|
||||
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{connector.type === "csv" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenCsvImport?.();
|
||||
}}>
|
||||
{t("workspace.unify.import_feedback")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -17,8 +17,16 @@ export const WorkspaceFeedbackSourcesPage = async (
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session, organization } =
|
||||
await getWorkspaceAuth(params.workspaceId);
|
||||
const {
|
||||
isOwner,
|
||||
isManager,
|
||||
hasReadAccess,
|
||||
hasReadWriteAccess,
|
||||
hasManageAccess,
|
||||
isReadOnly,
|
||||
session,
|
||||
organization,
|
||||
} = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
@@ -72,6 +80,7 @@ export const WorkspaceFeedbackSourcesPage = async (
|
||||
initialConnectors={connectors}
|
||||
initialSurveys={unifySurveys}
|
||||
directories={directories}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -220,10 +220,29 @@ export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSc
|
||||
export type TCreateConnectorStep = "selectType" | "mapping";
|
||||
|
||||
export const ZFormbricksConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
sourceName: z.string().trim().min(1, "CONNECTOR_NAME_REQUIRED"),
|
||||
surveyId: z.string().min(1, "CONNECTOR_SURVEY_REQUIRED"),
|
||||
selectedQuestionIds: z.array(z.string()).min(1, "CONNECTOR_QUESTIONS_REQUIRED"),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
|
||||
export type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
|
||||
|
||||
export const getTranslatedConnectorError = (errorCode: string, t: TFunction): string => {
|
||||
switch (errorCode) {
|
||||
case "CONNECTOR_NAME_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_name_duplicate");
|
||||
case "CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_formbricks_mapping_duplicate");
|
||||
case "CONNECTOR_FIELD_MAPPING_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_field_mapping_duplicate");
|
||||
case "CONNECTOR_NAME_REQUIRED":
|
||||
return t("workspace.unify.error_connector_name_required");
|
||||
case "CONNECTOR_SURVEY_REQUIRED":
|
||||
return t("workspace.unify.error_connector_survey_required");
|
||||
case "CONNECTOR_QUESTIONS_REQUIRED":
|
||||
return t("workspace.unify.error_connector_questions_required");
|
||||
default:
|
||||
return errorCode;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,3 +78,10 @@ export const ZUpdateFeedbackRecordAction = z.object({
|
||||
});
|
||||
|
||||
export type TUpdateFeedbackRecordAction = z.infer<typeof ZUpdateFeedbackRecordAction>;
|
||||
|
||||
export const ZDeleteFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordId: ZFeedbackRecordId,
|
||||
});
|
||||
|
||||
export type TDeleteFeedbackRecordAction = z.infer<typeof ZDeleteFeedbackRecordAction>;
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { feedbackRecordsEnvoyAuthorizer } from "@/modules/hub/feedback-records-gateway";
|
||||
import {
|
||||
TEnvoyRequestAuthorizer,
|
||||
authenticateEnvoyRequest,
|
||||
buildStatusResponse,
|
||||
parseEnvoyRequestMetadata,
|
||||
} from "./shared";
|
||||
|
||||
const envoyAuthorizers: TEnvoyRequestAuthorizer[] = [feedbackRecordsEnvoyAuthorizer];
|
||||
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
|
||||
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
|
||||
import { buildEnvoyAllowResponse, parseEnvoyRequestMetadata } from "./shared";
|
||||
|
||||
export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Response> => {
|
||||
const requestMetadata = parseEnvoyRequestMetadata(request);
|
||||
@@ -16,20 +10,12 @@ export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Respo
|
||||
return requestMetadata.errorResponse;
|
||||
}
|
||||
|
||||
const authorizer = envoyAuthorizers.find((candidate) => candidate.matches(requestMetadata.originalRequest));
|
||||
if (!authorizer) {
|
||||
return buildStatusResponse(400, "Unsupported Envoy auth route");
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateEnvoyRequest(request, authorizer.gatewayToken);
|
||||
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
|
||||
return buildStatusResponse(401, "Unauthorized");
|
||||
}
|
||||
|
||||
return await authorizer.authorize({
|
||||
return await authorizeGatewayRequest({
|
||||
request,
|
||||
originalRequest: requestMetadata.originalRequest,
|
||||
principal: authenticationResult.principal,
|
||||
authorizers: gatewayRequestAuthorizers,
|
||||
requestId: request.headers.get("x-request-id") ?? "unknown",
|
||||
buildAllowResponse: buildEnvoyAllowResponse,
|
||||
unsupportedRouteMessage: "Unsupported Envoy auth route",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,51 +1,11 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getProxySession } from "@/modules/auth/lib/proxy-session";
|
||||
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
|
||||
|
||||
const ENVOY_AUTH_PREFIX = "/api/envoy-auth";
|
||||
const HEADERS_TO_REMOVE_ON_ALLOW = "x-api-key,authorization,cookie";
|
||||
|
||||
export type TEnvoyOriginalRequest = {
|
||||
method: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type TEnvoyAuthenticatedPrincipal =
|
||||
| {
|
||||
type: "apiKey";
|
||||
authentication: TAuthenticationApiKey;
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
userId: string;
|
||||
source: "session" | "jwt";
|
||||
};
|
||||
|
||||
export type TEnvoyGatewayTokenHandler = {
|
||||
getTokenFromHeaders: (headers: Headers) => string | null;
|
||||
verifyToken: (token: string) => { userId: string };
|
||||
};
|
||||
|
||||
export type TEnvoyAuthenticationResult =
|
||||
| { status: "authenticated"; principal: TEnvoyAuthenticatedPrincipal }
|
||||
| { status: "invalid" }
|
||||
| { status: "missing" };
|
||||
|
||||
export type TEnvoyRequestAuthorizer = {
|
||||
matches: (originalRequest: TEnvoyOriginalRequest) => boolean;
|
||||
gatewayToken?: TEnvoyGatewayTokenHandler;
|
||||
authorize: (params: {
|
||||
request: NextRequest;
|
||||
originalRequest: TEnvoyOriginalRequest;
|
||||
principal: TEnvoyAuthenticatedPrincipal;
|
||||
requestId: string;
|
||||
}) => Promise<Response>;
|
||||
};
|
||||
|
||||
export const buildAllowResponse = (): Response =>
|
||||
export const buildEnvoyAllowResponse = (): Response =>
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -53,20 +13,12 @@ export const buildAllowResponse = (): Response =>
|
||||
},
|
||||
});
|
||||
|
||||
export const buildStatusResponse = (status: number, message: string): Response =>
|
||||
new Response(message, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
export const parseEnvoyRequestMetadata = (
|
||||
request: NextRequest
|
||||
): { originalRequest: TEnvoyOriginalRequest } | { errorResponse: Response } => {
|
||||
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
|
||||
if (!request.nextUrl.pathname.startsWith(`${ENVOY_AUTH_PREFIX}/`)) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid Envoy auth request path"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid Envoy auth request path"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,7 +29,7 @@ export const parseEnvoyRequestMetadata = (
|
||||
|
||||
if (originalPathSegments.length === 0) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Missing original request path"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Missing original request path"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,69 +45,7 @@ export const parseEnvoyRequestMetadata = (
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid original request path"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid original request path"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const authenticateEnvoyRequest = async (
|
||||
request: NextRequest,
|
||||
gatewayToken?: TEnvoyGatewayTokenHandler
|
||||
): Promise<TEnvoyAuthenticationResult> => {
|
||||
if (getApiKeyFromHeaders(request.headers)) {
|
||||
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
|
||||
if (!apiKeyAuthentication) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "apiKey",
|
||||
authentication: apiKeyAuthentication,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (gatewayToken) {
|
||||
const token = gatewayToken.getTokenFromHeaders(request.headers);
|
||||
if (token) {
|
||||
try {
|
||||
const { userId } = gatewayToken.verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || user.isActive === false) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: user.id,
|
||||
source: "jwt",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proxySession = await getProxySession(request);
|
||||
if (!proxySession) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: proxySession.userId,
|
||||
source: "session",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import "server-only";
|
||||
import { feedbackRecordsGatewayAuthorizer } from "@/modules/hub/feedback-records-gateway";
|
||||
import { TGatewayRequestAuthorizer } from "./request";
|
||||
|
||||
export const gatewayRequestAuthorizers: TGatewayRequestAuthorizer[] = [feedbackRecordsGatewayAuthorizer];
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { authenticateGatewayRequest } from "./request";
|
||||
|
||||
const {
|
||||
mockAuthenticateApiKeyFromHeaders,
|
||||
mockGetApiKeyFromHeaders,
|
||||
mockGetProxySession,
|
||||
mockUserFindUnique,
|
||||
mockLoggerWarn,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthenticateApiKeyFromHeaders: vi.fn(),
|
||||
mockGetApiKeyFromHeaders: vi.fn(),
|
||||
mockGetProxySession: vi.fn(),
|
||||
mockUserFindUnique: vi.fn(),
|
||||
mockLoggerWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/api-key-auth", () => ({
|
||||
authenticateApiKeyFromHeaders: mockAuthenticateApiKeyFromHeaders,
|
||||
getApiKeyFromHeaders: mockGetApiKeyFromHeaders,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/proxy-session", () => ({
|
||||
getProxySession: mockGetProxySession,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mockUserFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: mockLoggerWarn,
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("authenticateGatewayRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetApiKeyFromHeaders.mockReturnValue(null);
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue(null);
|
||||
mockGetProxySession.mockResolvedValue(null);
|
||||
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true });
|
||||
});
|
||||
|
||||
test("logs and returns invalid when an explicit API key cannot be authenticated", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_invalid");
|
||||
|
||||
const result = await authenticateGatewayRequest(new NextRequest("http://localhost/test"));
|
||||
|
||||
expect(result).toEqual({ status: "invalid" });
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
{ hasApiKey: true, reason: "invalid_api_key" },
|
||||
"Gateway authentication failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("logs and returns invalid when gateway token verification fails", async () => {
|
||||
const verifyError = new Error("invalid token");
|
||||
|
||||
const result = await authenticateGatewayRequest(new NextRequest("http://localhost/test"), {
|
||||
getTokenFromHeaders: () => "header.payload.signature",
|
||||
verifyToken: () => {
|
||||
throw verifyError;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "invalid" });
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
{ error: verifyError, hasToken: true, reason: "token_verification_failed" },
|
||||
"Gateway authentication failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("logs and returns invalid when the gateway token user is inactive", async () => {
|
||||
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: false });
|
||||
|
||||
const result = await authenticateGatewayRequest(new NextRequest("http://localhost/test"), {
|
||||
getTokenFromHeaders: () => "header.payload.signature",
|
||||
verifyToken: () => ({ userId: "user_1" }),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "invalid" });
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
{
|
||||
hasToken: true,
|
||||
reason: "user_missing_or_inactive",
|
||||
userId: "user_1",
|
||||
userFound: true,
|
||||
isActive: false,
|
||||
},
|
||||
"Gateway authentication failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("propagates user lookup errors instead of converting them into invalid auth", async () => {
|
||||
const lookupError = new Error("database unavailable");
|
||||
mockUserFindUnique.mockRejectedValue(lookupError);
|
||||
|
||||
await expect(
|
||||
authenticateGatewayRequest(new NextRequest("http://localhost/test"), {
|
||||
getTokenFromHeaders: () => "header.payload.signature",
|
||||
verifyToken: () => ({ userId: "user_1" }),
|
||||
})
|
||||
).rejects.toThrow("database unavailable");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getProxySession } from "@/modules/auth/lib/proxy-session";
|
||||
|
||||
export type TGatewayOriginalRequest = {
|
||||
method: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type TGatewayAuthenticatedPrincipal =
|
||||
| {
|
||||
type: "apiKey";
|
||||
authentication: TAuthenticationApiKey;
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
userId: string;
|
||||
source: "session" | "jwt";
|
||||
};
|
||||
|
||||
export type TGatewayTokenHandler = {
|
||||
getTokenFromHeaders: (headers: Headers) => string | null;
|
||||
verifyToken: (token: string) => { userId: string };
|
||||
};
|
||||
|
||||
export type TGatewayAuthenticationResult =
|
||||
| { status: "authenticated"; principal: TGatewayAuthenticatedPrincipal }
|
||||
| { status: "invalid" }
|
||||
| { status: "missing" };
|
||||
|
||||
export type TGatewayAuthorizationDecision = { status: "allow" } | { status: "deny"; response: Response };
|
||||
|
||||
export type TGatewayRequestAuthorizer = {
|
||||
matches: (originalRequest: TGatewayOriginalRequest) => boolean;
|
||||
gatewayToken?: TGatewayTokenHandler;
|
||||
authorize: (params: {
|
||||
request: NextRequest;
|
||||
originalRequest: TGatewayOriginalRequest;
|
||||
principal: TGatewayAuthenticatedPrincipal;
|
||||
requestId: string;
|
||||
}) => Promise<TGatewayAuthorizationDecision>;
|
||||
};
|
||||
|
||||
export const buildGatewayStatusResponse = (status: number, message: string): Response =>
|
||||
new Response(message, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
export const allowGatewayRequest = (): TGatewayAuthorizationDecision => ({ status: "allow" });
|
||||
|
||||
export const authenticateGatewayRequest = async (
|
||||
request: NextRequest,
|
||||
gatewayToken?: TGatewayTokenHandler
|
||||
): Promise<TGatewayAuthenticationResult> => {
|
||||
if (getApiKeyFromHeaders(request.headers)) {
|
||||
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
|
||||
if (!apiKeyAuthentication) {
|
||||
logger.warn({ hasApiKey: true, reason: "invalid_api_key" }, "Gateway authentication failed");
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "apiKey",
|
||||
authentication: apiKeyAuthentication,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (gatewayToken) {
|
||||
const token = gatewayToken.getTokenFromHeaders(request.headers);
|
||||
if (token) {
|
||||
let userId: string;
|
||||
|
||||
try {
|
||||
({ userId } = gatewayToken.verifyToken(token));
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, hasToken: true, reason: "token_verification_failed" },
|
||||
"Gateway authentication failed"
|
||||
);
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || user.isActive === false) {
|
||||
logger.warn(
|
||||
{
|
||||
hasToken: true,
|
||||
reason: "user_missing_or_inactive",
|
||||
userId,
|
||||
userFound: Boolean(user),
|
||||
isActive: user?.isActive ?? null,
|
||||
},
|
||||
"Gateway authentication failed"
|
||||
);
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: user.id,
|
||||
source: "jwt",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const proxySession = await getProxySession(request);
|
||||
if (!proxySession) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: proxySession.userId,
|
||||
source: "session",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const authorizeGatewayRequest = async ({
|
||||
request,
|
||||
originalRequest,
|
||||
authorizers,
|
||||
requestId,
|
||||
buildAllowResponse,
|
||||
unsupportedRouteMessage,
|
||||
}: {
|
||||
request: NextRequest;
|
||||
originalRequest: TGatewayOriginalRequest;
|
||||
authorizers: TGatewayRequestAuthorizer[];
|
||||
requestId: string;
|
||||
buildAllowResponse: () => Response;
|
||||
unsupportedRouteMessage: string;
|
||||
}): Promise<Response> => {
|
||||
const authorizer = authorizers.find((candidate) => candidate.matches(originalRequest));
|
||||
if (!authorizer) {
|
||||
return buildGatewayStatusResponse(400, unsupportedRouteMessage);
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateGatewayRequest(request, authorizer.gatewayToken);
|
||||
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
|
||||
return buildGatewayStatusResponse(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const authorizationDecision = await authorizer.authorize({
|
||||
request,
|
||||
originalRequest,
|
||||
principal: authenticationResult.principal,
|
||||
requestId,
|
||||
});
|
||||
|
||||
return authorizationDecision.status === "allow" ? buildAllowResponse() : authorizationDecision.response;
|
||||
};
|
||||
@@ -12,11 +12,11 @@ import { getBearerTokenFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getFeedbackDirectoryAuthContext } from "@/modules/ee/feedback-directory/lib/feedback-directory";
|
||||
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
TEnvoyAuthenticatedPrincipal,
|
||||
TEnvoyRequestAuthorizer,
|
||||
buildAllowResponse,
|
||||
buildStatusResponse,
|
||||
} from "@/modules/envoy-auth/shared";
|
||||
TGatewayAuthenticatedPrincipal,
|
||||
TGatewayRequestAuthorizer,
|
||||
allowGatewayRequest,
|
||||
buildGatewayStatusResponse,
|
||||
} from "@/modules/gateway-auth/lib/request";
|
||||
import { getFeedbackRecordTenant } from "@/modules/hub/service";
|
||||
|
||||
const FEEDBACK_RECORDS_V3_PREFIX = "/api/v3/feedbackRecords";
|
||||
@@ -137,7 +137,7 @@ const parseFeedbackRecordsGatewayRoute = (method: string, pathname: string): TPa
|
||||
return null;
|
||||
};
|
||||
|
||||
type TAuthenticatedGatewayPrincipal = TEnvoyAuthenticatedPrincipal;
|
||||
type TAuthenticatedGatewayPrincipal = TGatewayAuthenticatedPrincipal;
|
||||
|
||||
const parseTenantId = (tenantId: string | null): string | null => {
|
||||
if (!tenantId) {
|
||||
@@ -200,7 +200,7 @@ const resolveTenantId = async (
|
||||
const tenantId = parseTenantId(originalUrl.searchParams.get("tenant_id"));
|
||||
if (!tenantId) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ const resolveTenantId = async (
|
||||
const tenantId = parseTenantId(typeof body?.tenant_id === "string" ? body.tenant_id : null);
|
||||
if (!tenantId) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup returned not found"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildStatusResponse(403, "Forbidden"),
|
||||
errorResponse: buildGatewayStatusResponse(403, "Forbidden"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup failed"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
|
||||
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,14 +247,14 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup returned invalid tenant"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
|
||||
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
|
||||
};
|
||||
}
|
||||
|
||||
return { tenantId };
|
||||
};
|
||||
|
||||
const authorizeGatewayRequest = async (
|
||||
const authorizeFeedbackRecordsGatewayRequest = async (
|
||||
principal: TAuthenticatedGatewayPrincipal,
|
||||
feedbackDirectoryId: string,
|
||||
requiredPermission: TFeedbackRecordsGatewayPermission
|
||||
@@ -308,7 +308,7 @@ const authorizeGatewayRequest = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
|
||||
matches: (originalRequest) => normalizeFeedbackRecordsPath(originalRequest.url.pathname) !== null,
|
||||
gatewayToken: {
|
||||
getTokenFromHeaders: getFeedbackRecordsGatewayJwtFromHeaders,
|
||||
@@ -317,15 +317,21 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
authorize: async ({ request, originalRequest, principal, requestId }) => {
|
||||
const route = parseFeedbackRecordsGatewayRoute(originalRequest.method, originalRequest.url.pathname);
|
||||
if (!route) {
|
||||
return buildStatusResponse(400, "Unsupported FeedbackRecords route");
|
||||
return {
|
||||
status: "deny",
|
||||
response: buildGatewayStatusResponse(400, "Unsupported FeedbackRecords route"),
|
||||
};
|
||||
}
|
||||
|
||||
const tenantResolution = await resolveTenantId(request, route, originalRequest.url, requestId);
|
||||
if ("errorResponse" in tenantResolution) {
|
||||
return tenantResolution.errorResponse;
|
||||
return {
|
||||
status: "deny",
|
||||
response: tenantResolution.errorResponse,
|
||||
};
|
||||
}
|
||||
|
||||
const authorizationResult = await authorizeGatewayRequest(
|
||||
const authorizationResult = await authorizeFeedbackRecordsGatewayRequest(
|
||||
principal,
|
||||
tenantResolution.tenantId,
|
||||
route.requiredPermission
|
||||
@@ -341,7 +347,10 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
},
|
||||
"Feedback records gateway authorization denied"
|
||||
);
|
||||
return buildStatusResponse(403, "Forbidden");
|
||||
return {
|
||||
status: "deny",
|
||||
response: buildGatewayStatusResponse(403, "Forbidden"),
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -355,6 +364,6 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
"Feedback records gateway authorization allowed"
|
||||
);
|
||||
|
||||
return buildAllowResponse();
|
||||
return allowGatewayRequest();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import FormbricksHub from "@formbricks/hub";
|
||||
import {
|
||||
createFeedbackRecord,
|
||||
createFeedbackRecordsBatch,
|
||||
deleteFeedbackRecord,
|
||||
getFeedbackRecordTenant,
|
||||
listFeedbackRecords,
|
||||
retrieveFeedbackRecord,
|
||||
@@ -95,6 +96,24 @@ describe("hub service", () => {
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({ message: "Network error" });
|
||||
});
|
||||
|
||||
test("reads status from a foreign error class (simulates dual module scope)", async () => {
|
||||
// Simulates the SDK being loaded into a different module scope under Next dev/Turbopack:
|
||||
// the thrown error is NOT instanceof the FormbricksHub.APIError reference captured in service.ts.
|
||||
class ForeignConflictError extends Error {
|
||||
readonly status = 409;
|
||||
}
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: {
|
||||
create: vi.fn().mockRejectedValue(new ForeignConflictError("duplicate submission_id")),
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackRecord(sampleInput);
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toMatchObject({ status: 409, message: "duplicate submission_id" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listFeedbackRecords", () => {
|
||||
@@ -278,6 +297,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);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import FormbricksHub from "@formbricks/hub";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { getHubClient } from "./hub-client";
|
||||
@@ -33,8 +32,15 @@ const getErrorMessage = (err: unknown): string => {
|
||||
return "Unknown error";
|
||||
};
|
||||
|
||||
// Duck-typed: `instanceof` against the SDK error class breaks under Next dev/Turbopack
|
||||
// when @formbricks/hub is loaded into more than one module scope.
|
||||
const getErrorStatus = (err: unknown): number =>
|
||||
err && typeof err === "object" && typeof (err as { status?: unknown }).status === "number"
|
||||
? (err as { status: number }).status
|
||||
: 0;
|
||||
|
||||
const createResultFromError = (err: unknown): HubFeedbackRecordResult => {
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const status = getErrorStatus(err);
|
||||
const message = getErrorMessage(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
};
|
||||
@@ -98,6 +104,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 = getErrorStatus(err);
|
||||
const message = getErrorMessage(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
}
|
||||
};
|
||||
|
||||
export type ListFeedbackRecordsResult = {
|
||||
data: FeedbackRecordListResponse | null;
|
||||
error: HubError | null;
|
||||
@@ -128,7 +159,7 @@ export const listFeedbackRecords = async (
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Hub: listFeedbackRecords failed");
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const status = getErrorStatus(err);
|
||||
const message = getErrorMessage(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
}
|
||||
@@ -146,7 +177,7 @@ export const semanticSearchFeedbackRecords = async (
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, tenantId: input.tenant_id }, "Hub: semanticSearchFeedbackRecords failed");
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const status = getErrorStatus(err);
|
||||
const message = getErrorMessage(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
}
|
||||
@@ -171,7 +202,7 @@ export const getFeedbackRecordTenant = async (recordId: string): Promise<Feedbac
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, recordId }, "Hub: getFeedbackRecordTenant failed");
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const status = getErrorStatus(err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export const selectSurvey = {
|
||||
singleUse: true,
|
||||
pin: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
recaptcha: true,
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SurveyCompletedMessage } from "@/modules/survey/link/components/survey-
|
||||
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
|
||||
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
|
||||
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
|
||||
import { getSurveyLanguageCode } from "@/modules/survey/link/lib/utils";
|
||||
import { TWorkspaceContextForLinkSurvey } from "@/modules/survey/link/lib/workspace";
|
||||
|
||||
interface SurveyRendererProps {
|
||||
@@ -36,6 +37,7 @@ interface SurveyRendererProps {
|
||||
// New props - pre-fetched in parent
|
||||
workspaceContext: TWorkspaceContextForLinkSurvey;
|
||||
locale: TUserLocale;
|
||||
browserLanguageCodes?: string[];
|
||||
responseCount?: number;
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ export const renderSurvey = async ({
|
||||
isPreview,
|
||||
workspaceContext,
|
||||
locale,
|
||||
browserLanguageCodes = [],
|
||||
responseCount,
|
||||
}: SurveyRendererProps) => {
|
||||
const langParam = searchParams.lang;
|
||||
@@ -110,7 +113,7 @@ export const renderSurvey = async ({
|
||||
<VerifyEmail
|
||||
survey={survey}
|
||||
isErrorComponent={true}
|
||||
languageCode={getLanguageCode(langParam, survey)}
|
||||
languageCode={getSurveyLanguageCode(langParam, survey, browserLanguageCodes)}
|
||||
styling={workspace.styling}
|
||||
locale={locale}
|
||||
/>
|
||||
@@ -120,7 +123,7 @@ export const renderSurvey = async ({
|
||||
<VerifyEmail
|
||||
singleUseId={searchParams.suId ?? ""}
|
||||
survey={survey}
|
||||
languageCode={getLanguageCode(langParam, survey)}
|
||||
languageCode={getSurveyLanguageCode(langParam, survey, browserLanguageCodes)}
|
||||
styling={workspace.styling}
|
||||
locale={locale}
|
||||
/>
|
||||
@@ -129,7 +132,7 @@ export const renderSurvey = async ({
|
||||
|
||||
// Compute final styling based on workspace and survey settings
|
||||
const styling = computeStyling(workspace.styling, survey.styling);
|
||||
const languageCode = getLanguageCode(langParam, survey);
|
||||
const languageCode = getSurveyLanguageCode(langParam, survey, browserLanguageCodes);
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
// Handle PIN-protected surveys
|
||||
@@ -196,24 +199,3 @@ function computeStyling(
|
||||
}
|
||||
return surveyStyling?.overwriteThemeStyling ? surveyStyling : workspaceStyling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the language code to use for the survey.
|
||||
* Checks URL parameter against available survey languages and returns
|
||||
* "default" if language is not found or disabled.
|
||||
*/
|
||||
function getLanguageCode(langParam: string | undefined, survey: TSurvey): string {
|
||||
if (!langParam) return "default";
|
||||
|
||||
const selectedLanguage = survey.languages.find((surveyLanguage) => {
|
||||
return (
|
||||
surveyLanguage.language.code.toLowerCase() === langParam.toLowerCase() ||
|
||||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
|
||||
return "default";
|
||||
}
|
||||
return selectedLanguage.language.code;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { verifyContactSurveyToken } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
|
||||
@@ -136,10 +136,11 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
singleUseId = validatedSingleUseId;
|
||||
}
|
||||
|
||||
// Parallel fetch of environment context and locale
|
||||
const [workspaceContext, locale, singleUseResponse] = await Promise.all([
|
||||
// Parallel fetch of workspace context, locale, browser language, and contact response
|
||||
const [workspaceContext, locale, browserLanguageCodes, singleUseResponse] = await Promise.all([
|
||||
getWorkspaceContextForLinkSurvey(survey.workspaceId),
|
||||
findMatchingLocale(),
|
||||
findMatchingBrowserLanguageCodes(),
|
||||
// Fetch existing response for this contact
|
||||
getExistingContactResponse(survey.id, contactId)(),
|
||||
]);
|
||||
@@ -158,6 +159,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
singleUseResponse,
|
||||
workspaceContext,
|
||||
locale,
|
||||
browserLanguageCodes,
|
||||
responseCount,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -58,6 +58,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
styling: true,
|
||||
surveyClosedMessage: true,
|
||||
showLanguageSwitch: true,
|
||||
autoSelectLanguage: true,
|
||||
recaptcha: true,
|
||||
metadata: true,
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@ import { TJsWorkspaceStateSurvey } from "@formbricks/types/js";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getElementsFromSurveyBlocks, getWebAppLocale, isRTL, isRTLLanguage } from "./utils";
|
||||
import {
|
||||
getElementsFromSurveyBlocks,
|
||||
getSurveyLanguageCode,
|
||||
getWebAppLocale,
|
||||
isRTL,
|
||||
isRTLLanguage,
|
||||
} from "./utils";
|
||||
|
||||
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
|
||||
({
|
||||
@@ -45,6 +51,7 @@ const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
showLanguageSwitch: null,
|
||||
autoSelectLanguage: null,
|
||||
recaptcha: null,
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
@@ -98,6 +105,80 @@ describe("getWebAppLocale", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSurveyLanguageCode", () => {
|
||||
const language = (code: string, overrides: Partial<TSurvey["languages"][number]> = {}) => ({
|
||||
language: {
|
||||
id: `lang-${code}`,
|
||||
code,
|
||||
alias: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "p1",
|
||||
},
|
||||
default: false,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("uses the URL language parameter before browser language auto-selection", () => {
|
||||
const survey = {
|
||||
...createMockSurvey([language("en", { default: true }), language("de")]),
|
||||
autoSelectLanguage: true,
|
||||
};
|
||||
|
||||
expect(getSurveyLanguageCode("de", survey, ["en-US"])).toBe("de");
|
||||
});
|
||||
|
||||
test("matches browser language exactly when auto-selection is enabled", () => {
|
||||
const survey = {
|
||||
...createMockSurvey([language("en", { default: true }), language("de-DE")]),
|
||||
autoSelectLanguage: true,
|
||||
};
|
||||
|
||||
expect(getSurveyLanguageCode(undefined, survey, ["de-DE", "en-US"])).toBe("de-DE");
|
||||
});
|
||||
|
||||
test("matches browser language by base language when exact variant is unavailable", () => {
|
||||
const survey = {
|
||||
...createMockSurvey([language("en", { default: true }), language("es-ES")]),
|
||||
autoSelectLanguage: true,
|
||||
};
|
||||
|
||||
expect(getSurveyLanguageCode(undefined, survey, ["es-MX", "en-US"])).toBe("es-ES");
|
||||
});
|
||||
|
||||
test("uses aliases and ignores disabled languages", () => {
|
||||
const survey = {
|
||||
...createMockSurvey([
|
||||
language("en", { default: true }),
|
||||
language("de", { enabled: false }),
|
||||
language("fr-FR", {
|
||||
language: {
|
||||
id: "lang-fr-FR",
|
||||
code: "fr-FR",
|
||||
alias: "fr",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "p1",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
autoSelectLanguage: true,
|
||||
};
|
||||
|
||||
expect(getSurveyLanguageCode(undefined, survey, ["de-DE", "fr-CA"])).toBe("fr-FR");
|
||||
});
|
||||
|
||||
test("falls back to default language when auto-selection is disabled or unmatched", () => {
|
||||
const survey = createMockSurvey([language("en", { default: true }), language("de")]);
|
||||
|
||||
expect(getSurveyLanguageCode(undefined, survey, ["de-DE"])).toBe("default");
|
||||
expect(getSurveyLanguageCode(undefined, { ...survey, autoSelectLanguage: true }, ["fr-FR"])).toBe(
|
||||
"default"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRTL", () => {
|
||||
test("detects RTL characters", () => {
|
||||
expect(isRTL("مرحبا")).toBe(true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TJsWorkspaceStateSurvey } from "@formbricks/types/js";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { resolveSurveyLanguage } from "@formbricks/types/surveys/language";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export function isRTL(text: string): boolean {
|
||||
@@ -55,6 +56,22 @@ export function isRTLLanguage(survey: TJsWorkspaceStateSurvey, languageCode: str
|
||||
export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
|
||||
blocks.flatMap((block) => block.elements);
|
||||
|
||||
export const getSurveyLanguageCode = (
|
||||
langParam: string | undefined,
|
||||
survey: TSurvey,
|
||||
browserLanguageCodes: string[] = []
|
||||
): string => {
|
||||
return (
|
||||
resolveSurveyLanguage({
|
||||
languages: survey.languages,
|
||||
explicitLanguageCode: langParam,
|
||||
browserLanguageCodes,
|
||||
autoSelectLanguage: survey.autoSelectLanguage,
|
||||
unmatchedExplicitLanguageBehavior: "fallback",
|
||||
}) ?? "default"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps survey language codes to web app locale codes.
|
||||
* Falls back to "en-US" if the language is not available in web app locales.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
|
||||
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
|
||||
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
|
||||
@@ -93,17 +93,18 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
|
||||
if (isSingleUseSurvey) {
|
||||
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
|
||||
if (!validatedSingleUseId) {
|
||||
// Need to fetch workspace for error page - fetch environmentContext for it
|
||||
const environmentContext = await getWorkspaceContextForLinkSurvey(survey.workspaceId);
|
||||
return <SurveyInactive status="link invalid" workspace={environmentContext.workspace} />;
|
||||
// Need to fetch workspace for error page.
|
||||
const workspaceContext = await getWorkspaceContextForLinkSurvey(survey.workspaceId);
|
||||
return <SurveyInactive status="link invalid" workspace={workspaceContext.workspace} />;
|
||||
}
|
||||
singleUseId = validatedSingleUseId;
|
||||
}
|
||||
|
||||
// Stage 2: Parallel fetch of all remaining data
|
||||
const [workspaceContext, locale, singleUseResponse] = await Promise.all([
|
||||
const [workspaceContext, locale, browserLanguageCodes, singleUseResponse] = await Promise.all([
|
||||
getWorkspaceContextForLinkSurvey(survey.workspaceId),
|
||||
findMatchingLocale(),
|
||||
findMatchingBrowserLanguageCodes(),
|
||||
// Only fetch single-use response if we have a validated ID
|
||||
isSingleUseSurvey && singleUseId
|
||||
? getResponseBySingleUseId(survey.id, singleUseId)()
|
||||
@@ -124,6 +125,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
|
||||
isPreview,
|
||||
workspaceContext,
|
||||
locale,
|
||||
browserLanguageCodes,
|
||||
responseCount,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -149,7 +149,7 @@ export const LanguageView = ({
|
||||
buttonVariant: "destructive",
|
||||
onConfirm: () => {
|
||||
// Strip all non-default language keys from the survey data
|
||||
let cleanedSurvey = localSurvey;
|
||||
let cleanedSurvey = { ...localSurvey, autoSelectLanguage: false };
|
||||
for (const lang of localSurvey.languages) {
|
||||
if (!lang.default) {
|
||||
cleanedSurvey = removeLanguageKeysFromSurvey(cleanedSurvey, lang.language.code);
|
||||
@@ -172,6 +172,7 @@ export const LanguageView = ({
|
||||
const language = workspaceLanguages.find((lang) => lang.code === languageCode);
|
||||
if (!language) return;
|
||||
|
||||
const isNewMultiLanguageSurvey = localSurvey.languages.length === 0;
|
||||
let languageExists = false;
|
||||
const newLanguages =
|
||||
localSurvey.languages.map((lang) => {
|
||||
@@ -187,7 +188,11 @@ export const LanguageView = ({
|
||||
}
|
||||
|
||||
setConfirmationModalInfo((prev) => ({ ...prev, open: false }));
|
||||
setLocalSurvey({ ...localSurvey, languages: newLanguages });
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
languages: newLanguages,
|
||||
autoSelectLanguage: isNewMultiLanguageSurvey ? true : localSurvey.autoSelectLanguage,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleLanguage = (code: string) => {
|
||||
@@ -254,7 +259,7 @@ export const LanguageView = ({
|
||||
buttonText: t("workspace.surveys.edit.remove_translations"),
|
||||
buttonVariant: "destructive",
|
||||
onConfirm: () => {
|
||||
updateSurveyTranslations(localSurvey, []);
|
||||
updateSurveyTranslations({ ...localSurvey, autoSelectLanguage: false }, []);
|
||||
setIsMultiLanguageActivated(false);
|
||||
setConfirmationModalInfo((prev) => ({ ...prev, open: false }));
|
||||
},
|
||||
@@ -265,6 +270,10 @@ export const LanguageView = ({
|
||||
setLocalSurvey({ ...localSurvey, showLanguageSwitch: !localSurvey.showLanguageSwitch });
|
||||
};
|
||||
|
||||
const handleAutoSelectLanguageToggle = () => {
|
||||
setLocalSurvey({ ...localSurvey, autoSelectLanguage: !localSurvey.autoSelectLanguage });
|
||||
};
|
||||
|
||||
const openTranslationModal = (code: string) => {
|
||||
setActiveLanguageCode(code);
|
||||
setTranslationModalOpen(true);
|
||||
@@ -489,6 +498,16 @@ export const LanguageView = ({
|
||||
)}
|
||||
childBorder={true}
|
||||
/>
|
||||
<AdvancedOptionToggle
|
||||
customContainerClass="px-0 pt-0"
|
||||
htmlId="autoSelectLanguage"
|
||||
disabled={enabledLanguages.length <= 1}
|
||||
isChecked={!!localSurvey.autoSelectLanguage}
|
||||
onToggle={handleAutoSelectLanguageToggle}
|
||||
title={t("workspace.surveys.edit.auto_select_browser_language")}
|
||||
description={t("workspace.surveys.edit.auto_select_browser_language_description")}
|
||||
childBorder={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
|
||||
segment: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
autoSelectLanguage: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
variables: [],
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { authorizeTraefikRequest } from "./service";
|
||||
|
||||
const {
|
||||
mockAuthenticateApiKeyFromHeaders,
|
||||
mockGetApiKeyFromHeaders,
|
||||
mockGetBearerTokenFromHeaders,
|
||||
mockGetProxySession,
|
||||
mockVerifyFeedbackRecordsGatewayToken,
|
||||
mockGetFeedbackDirectoryAuthContext,
|
||||
mockGetFeedbackRecordTenant,
|
||||
mockCheckAuthorizationUpdated,
|
||||
mockUserFindUnique,
|
||||
mockGetIsUnifyFeedbackEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthenticateApiKeyFromHeaders: vi.fn(),
|
||||
mockGetApiKeyFromHeaders: vi.fn(),
|
||||
mockGetBearerTokenFromHeaders: vi.fn(),
|
||||
mockGetProxySession: vi.fn(),
|
||||
mockVerifyFeedbackRecordsGatewayToken: vi.fn(),
|
||||
mockGetFeedbackDirectoryAuthContext: vi.fn(),
|
||||
mockGetFeedbackRecordTenant: vi.fn(),
|
||||
mockCheckAuthorizationUpdated: vi.fn(),
|
||||
mockUserFindUnique: vi.fn(),
|
||||
mockGetIsUnifyFeedbackEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/api-key-auth", () => ({
|
||||
authenticateApiKeyFromHeaders: mockAuthenticateApiKeyFromHeaders,
|
||||
getApiKeyFromHeaders: mockGetApiKeyFromHeaders,
|
||||
getBearerTokenFromHeaders: mockGetBearerTokenFromHeaders,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/proxy-session", () => ({
|
||||
getProxySession: mockGetProxySession,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jwt", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/jwt")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyFeedbackRecordsGatewayToken: mockVerifyFeedbackRecordsGatewayToken,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mockUserFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({
|
||||
getFeedbackDirectoryAuthContext: mockGetFeedbackDirectoryAuthContext,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsUnifyFeedbackEnabled: mockGetIsUnifyFeedbackEnabled,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/hub/service", () => ({
|
||||
getFeedbackRecordTenant: mockGetFeedbackRecordTenant,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: mockCheckAuthorizationUpdated,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const feedbackDirectoryId = "clxx1234567890123456789012";
|
||||
const feedbackRecordId = "0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8";
|
||||
|
||||
const createRequest = ({
|
||||
method = "GET",
|
||||
forwardedMethod = "GET",
|
||||
forwardedUri,
|
||||
headers = {},
|
||||
body,
|
||||
adapterUrl = "http://localhost/api/traefik-auth/feedback-records",
|
||||
}: {
|
||||
method?: string;
|
||||
forwardedMethod?: string;
|
||||
forwardedUri?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyInit;
|
||||
adapterUrl?: string;
|
||||
} = {}) =>
|
||||
new NextRequest(adapterUrl, {
|
||||
method,
|
||||
headers: {
|
||||
"x-forwarded-method": forwardedMethod,
|
||||
...(forwardedUri ? { "x-forwarded-uri": forwardedUri } : {}),
|
||||
"x-forwarded-host": "app.example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
...headers,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
describe("authorizeTraefikRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetApiKeyFromHeaders.mockReturnValue(null);
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue(null);
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue(null);
|
||||
mockGetProxySession.mockResolvedValue(null);
|
||||
mockVerifyFeedbackRecordsGatewayToken.mockImplementation(() => {
|
||||
throw new Error("invalid token");
|
||||
});
|
||||
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
|
||||
organizationId: "org_1",
|
||||
workspaceIds: ["workspace_1"],
|
||||
isArchived: false,
|
||||
});
|
||||
mockGetFeedbackRecordTenant.mockResolvedValue({
|
||||
data: { tenantId: feedbackDirectoryId },
|
||||
error: null,
|
||||
});
|
||||
mockCheckAuthorizationUpdated.mockResolvedValue(true);
|
||||
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true });
|
||||
mockGetIsUnifyFeedbackEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("allows requests using Traefik forwarded method and URI metadata", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
method: "POST",
|
||||
forwardedMethod: "POST",
|
||||
forwardedUri: "/api/v3/feedbackRecords",
|
||||
headers: {
|
||||
authorization: "Bearer fbk_test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("x-envoy-auth-headers-to-remove")).toBeNull();
|
||||
});
|
||||
|
||||
test("uses the forwarded URI instead of the Traefik auth endpoint URL", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
adapterUrl: "http://localhost/api/traefik-auth/not-the-original-route",
|
||||
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("returns 400 when Traefik forwarded metadata is missing", async () => {
|
||||
const response = await authorizeTraefikRequest(
|
||||
new NextRequest("http://localhost/api/traefik-auth/v1/feedback-records", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("authorizes record lookups through the shared FeedbackRecords authorizer", async () => {
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
|
||||
mockVerifyFeedbackRecordsGatewayToken.mockReturnValue({ userId: "user_1" });
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
forwardedMethod: "PATCH",
|
||||
forwardedUri: `/v1/feedback-records/${feedbackRecordId}`,
|
||||
headers: {
|
||||
authorization: "Bearer header.payload.signature",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetFeedbackRecordTenant).toHaveBeenCalledWith(feedbackRecordId);
|
||||
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId: "org_1",
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
workspaceId: "workspace_1",
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 401 for invalid explicit JWT instead of falling back to session cookies", async () => {
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
|
||||
mockGetProxySession.mockResolvedValue({
|
||||
userId: "user_1",
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
|
||||
headers: {
|
||||
authorization: "Bearer header.payload.signature",
|
||||
cookie: "next-auth.session-token=still-present",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockGetProxySession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
|
||||
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
|
||||
import { buildTraefikAllowResponse, parseTraefikRequestMetadata } from "./shared";
|
||||
|
||||
export const authorizeTraefikRequest = async (request: NextRequest): Promise<Response> => {
|
||||
const requestMetadata = parseTraefikRequestMetadata(request);
|
||||
if ("errorResponse" in requestMetadata) {
|
||||
return requestMetadata.errorResponse;
|
||||
}
|
||||
|
||||
return await authorizeGatewayRequest({
|
||||
request,
|
||||
originalRequest: requestMetadata.originalRequest,
|
||||
authorizers: gatewayRequestAuthorizers,
|
||||
requestId: request.headers.get("x-request-id") ?? request.headers.get("x-forwarded-for") ?? "unknown",
|
||||
buildAllowResponse: buildTraefikAllowResponse,
|
||||
unsupportedRouteMessage: "Unsupported Traefik auth route",
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
|
||||
|
||||
const TRAEFIK_AUTH_PREFIX = "/api/traefik-auth";
|
||||
|
||||
const isTraefikAuthPath = (pathname: string): boolean =>
|
||||
pathname === TRAEFIK_AUTH_PREFIX || pathname.startsWith(`${TRAEFIK_AUTH_PREFIX}/`);
|
||||
|
||||
const buildForwardedRequestUrl = (request: NextRequest, forwardedUri: string): URL => {
|
||||
if (forwardedUri.startsWith("http://") || forwardedUri.startsWith("https://")) {
|
||||
return new URL(forwardedUri);
|
||||
}
|
||||
|
||||
const proto = request.headers.get("x-forwarded-proto") || "https";
|
||||
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || "traefik-auth.local";
|
||||
const normalizedUri = forwardedUri.startsWith("/") ? forwardedUri : `/${forwardedUri}`;
|
||||
|
||||
return new URL(normalizedUri, `${proto}://${host}`);
|
||||
};
|
||||
|
||||
export const buildTraefikAllowResponse = (): Response => new Response(null, { status: 200 });
|
||||
|
||||
export const parseTraefikRequestMetadata = (
|
||||
request: NextRequest
|
||||
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
|
||||
if (!isTraefikAuthPath(request.nextUrl.pathname)) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid Traefik auth request path"),
|
||||
};
|
||||
}
|
||||
|
||||
const forwardedMethod = request.headers.get("x-forwarded-method")?.trim();
|
||||
const forwardedUri = request.headers.get("x-forwarded-uri")?.trim();
|
||||
|
||||
if (!forwardedMethod || !forwardedUri) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Missing original request metadata"),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
originalRequest: {
|
||||
method: forwardedMethod.toUpperCase(),
|
||||
url: buildForwardedRequestUrl(request, forwardedUri),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid original request URI"),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -126,7 +126,8 @@ const FormError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const errorMessage = error?.message || error?.root?.message;
|
||||
const body = error ? String(errorMessage) : children;
|
||||
// Explicit children win — they're typically a translated/formatted version of the raw error.
|
||||
const body = children ?? (error ? String(errorMessage) : null);
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { Button } from "../button";
|
||||
|
||||
interface IconAction {
|
||||
icon: LucideIcon;
|
||||
icon: LucideIcon | null;
|
||||
tooltip: string;
|
||||
onClick?: () => void;
|
||||
isVisible?: boolean;
|
||||
@@ -37,7 +37,7 @@ export const IconBar = ({ actions }: IconBarProps) => {
|
||||
disabled={action.disabled}
|
||||
loading={action.isLoading}
|
||||
aria-label={action.tooltip}>
|
||||
<action.icon />
|
||||
{action.icon ? <action.icon /> : null}
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -28,6 +28,32 @@ const SelectTrigger = React.forwardRef<
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
|
||||
{...props}>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
|
||||
{...props}>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
@@ -39,18 +65,16 @@ const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = R
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white text-slate-700 shadow-md animate-in fade-in-80 dark:bg-slate-700 dark:text-slate-300",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
"max-h-[var(--radix-select-content-available-height)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}>
|
||||
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
@@ -98,6 +122,8 @@ export {
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
|
||||
@@ -48,10 +48,15 @@ The intended defaults are:
|
||||
|
||||
## Cube.js for XM Suite v5
|
||||
|
||||
This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features require an external Cube instance.
|
||||
XM Suite v5 dashboard and analysis features require Cube.js. Set `cube.enabled=true` to deploy an
|
||||
internal Cube service from this chart, or provide an external Cube endpoint.
|
||||
|
||||
- Set `deployment.env.CUBEJS_API_URL` to your Cube endpoint.
|
||||
- For chart-managed Cube, set `deployment.env.CUBEJS_API_URL` to `http://formbricks-cube:4000`
|
||||
when using the default release name.
|
||||
- For external Cube, set `deployment.env.CUBEJS_API_URL` to your Cube endpoint.
|
||||
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
|
||||
- Provide `CUBEJS_DB_*` connection variables to the Cube deployment through `cube.envFrom` or `cube.env`.
|
||||
- Keep `cube.replicas=1` while `cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER` is `memory`. Configure Cube Store before running multiple Cube replicas.
|
||||
- Keep Hub enabled. Cube should point at the same feedback records database that Hub writes to, unless you intentionally split that storage.
|
||||
|
||||
## Hub worker and self-hosted embeddings
|
||||
@@ -181,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"` | |
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/* eslint-env es2022 */
|
||||
|
||||
const TENANT_MEMBERS = ["FeedbackRecords.tenantId"];
|
||||
const REQUIRED_SCOPE = "xm:cube:query";
|
||||
|
||||
function assertRequiredEnvironmentVariable(name) {
|
||||
const value = process.env[name];
|
||||
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${name} is required to run Cube`);
|
||||
}
|
||||
}
|
||||
|
||||
assertRequiredEnvironmentVariable("CUBEJS_API_SECRET");
|
||||
|
||||
function getStringClaim(securityContext, claim) {
|
||||
const value = securityContext?.[claim];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
return trimmedValue.length > 0 ? trimmedValue : null;
|
||||
}
|
||||
|
||||
function getRequiredStringClaim(securityContext, claim) {
|
||||
const value = getStringClaim(securityContext, claim);
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Cube query rejected: missing ${claim} security context`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectFilterMembers(filters) {
|
||||
if (!Array.isArray(filters)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return filters.flatMap((filter) => [
|
||||
...(typeof filter?.member === "string" ? [filter.member] : []),
|
||||
...(typeof filter?.dimension === "string" ? [filter.dimension] : []),
|
||||
...collectFilterMembers(filter?.and),
|
||||
...collectFilterMembers(filter?.or),
|
||||
]);
|
||||
}
|
||||
|
||||
function collectOrderMembers(order) {
|
||||
if (!order) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(order)) {
|
||||
return order
|
||||
.map((orderEntry) => (Array.isArray(orderEntry) ? orderEntry[0] : null))
|
||||
.filter((member) => typeof member === "string");
|
||||
}
|
||||
|
||||
if (typeof order === "object") {
|
||||
return Object.keys(order);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function collectTimeDimensionMembers(timeDimensions) {
|
||||
if (!Array.isArray(timeDimensions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return timeDimensions
|
||||
.map((timeDimension) => timeDimension?.dimension)
|
||||
.filter((dimension) => typeof dimension === "string");
|
||||
}
|
||||
|
||||
function collectQueryMembers(query) {
|
||||
const cubeQuery = query ?? {};
|
||||
const members = [
|
||||
...(Array.isArray(cubeQuery.measures) ? cubeQuery.measures : []),
|
||||
...(Array.isArray(cubeQuery.dimensions) ? cubeQuery.dimensions : []),
|
||||
...(Array.isArray(cubeQuery.segments) ? cubeQuery.segments : []),
|
||||
...collectTimeDimensionMembers(cubeQuery.timeDimensions),
|
||||
...collectFilterMembers(cubeQuery.filters),
|
||||
...collectOrderMembers(cubeQuery.order),
|
||||
].filter((member) => typeof member === "string");
|
||||
|
||||
return Array.from(new Set(members)).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function assertValidSecurityContext(securityContext) {
|
||||
const tenantId = getRequiredStringClaim(securityContext, "tenantId");
|
||||
const feedbackDirectoryId = getRequiredStringClaim(securityContext, "feedbackDirectoryId");
|
||||
const workspaceId = getRequiredStringClaim(securityContext, "workspaceId");
|
||||
const scope = getRequiredStringClaim(securityContext, "scope");
|
||||
|
||||
if (scope !== REQUIRED_SCOPE) {
|
||||
throw new Error("Cube query rejected: invalid Cube query scope");
|
||||
}
|
||||
if (tenantId !== feedbackDirectoryId) {
|
||||
throw new Error("Cube query rejected: tenantId/feedbackDirectoryId mismatch");
|
||||
}
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
feedbackDirectoryId,
|
||||
workspaceId,
|
||||
organizationId: getRequiredStringClaim(securityContext, "organizationId"),
|
||||
userId: getRequiredStringClaim(securityContext, "userId"),
|
||||
requestId: getRequiredStringClaim(securityContext, "jti"),
|
||||
source: getRequiredStringClaim(securityContext, "source"),
|
||||
};
|
||||
}
|
||||
|
||||
function assertNoCallerTenantMember(query) {
|
||||
for (const member of collectQueryMembers(query)) {
|
||||
if (TENANT_MEMBERS.includes(member)) {
|
||||
throw new Error("Cube query rejected: tenant filters are enforced by Cube");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logCubeQueryAuditEvent(context, query, { error, status = "success" } = {}) {
|
||||
const errorName = error instanceof Error ? error.name : undefined;
|
||||
const errorMessage = error instanceof Error ? error.message : error ? String(error) : undefined;
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type: "audit",
|
||||
event: "cube.query",
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
tenantId: context.tenantId,
|
||||
feedbackDirectoryId: context.feedbackDirectoryId,
|
||||
workspaceId: context.workspaceId,
|
||||
organizationId: context.organizationId,
|
||||
userId: context.userId,
|
||||
requestId: context.requestId,
|
||||
source: context.source,
|
||||
members: collectQueryMembers(query),
|
||||
...(errorName ? { errorName } : {}),
|
||||
...(errorMessage ? { errorMessage } : {}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function logCubeQuerySecurityContextFailure(query, error) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type: "audit",
|
||||
event: "cube.query",
|
||||
status: "failure",
|
||||
timestamp: new Date().toISOString(),
|
||||
members: collectQueryMembers(query),
|
||||
errorName: error instanceof Error ? error.name : undefined,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function queryRewrite(query, rewriteContext) {
|
||||
const cubeQuery = query ?? {};
|
||||
let context;
|
||||
|
||||
try {
|
||||
context = assertValidSecurityContext(rewriteContext?.securityContext);
|
||||
} catch (error) {
|
||||
logCubeQuerySecurityContextFailure(cubeQuery, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
assertNoCallerTenantMember(cubeQuery);
|
||||
} catch (error) {
|
||||
logCubeQueryAuditEvent(context, cubeQuery, { error, status: "failure" });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const queriedCubePrefixes = new Set(collectQueryMembers(cubeQuery).map((member) => member.split(".")[0]));
|
||||
const rewrittenQuery = {
|
||||
...cubeQuery,
|
||||
filters: [
|
||||
...(Array.isArray(cubeQuery.filters) ? cubeQuery.filters : []),
|
||||
...TENANT_MEMBERS.filter((member) => queriedCubePrefixes.has(member.split(".")[0])).map(
|
||||
(member) => ({
|
||||
member,
|
||||
operator: "equals",
|
||||
values: [context.tenantId],
|
||||
})
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
logCubeQueryAuditEvent(context, rewrittenQuery);
|
||||
return rewrittenQuery;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queryRewrite,
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
|
||||
// If the Hub changes column names or types, this schema must be updated to match.
|
||||
cube(`FeedbackRecords`, {
|
||||
sql: `SELECT * FROM feedback_records`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
description: `Total number of feedback responses`,
|
||||
},
|
||||
|
||||
promoterCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 9` }],
|
||||
description: `Number of promoters (NPS score 9-10)`,
|
||||
},
|
||||
|
||||
detractorCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6` }],
|
||||
description: `Number of detractors (NPS score 0-6)`,
|
||||
},
|
||||
|
||||
passiveCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
|
||||
description: `Number of passives (NPS score 7-8)`,
|
||||
},
|
||||
|
||||
npsScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
(
|
||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
/ COUNT(*)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
|
||||
},
|
||||
|
||||
averageScore: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
description: `Average NPS score`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `id`,
|
||||
type: `string`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
sourceType: {
|
||||
sql: `source_type`,
|
||||
type: `string`,
|
||||
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
|
||||
},
|
||||
|
||||
sourceName: {
|
||||
sql: `source_name`,
|
||||
type: `string`,
|
||||
description: `Human-readable name of the source`,
|
||||
},
|
||||
|
||||
fieldType: {
|
||||
sql: `field_type`,
|
||||
type: `string`,
|
||||
description: `Type of feedback field (e.g., nps, text, rating)`,
|
||||
},
|
||||
|
||||
collectedAt: {
|
||||
sql: `collected_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback was collected`,
|
||||
},
|
||||
|
||||
npsValue: {
|
||||
sql: `value_number`,
|
||||
type: `number`,
|
||||
description: `Raw NPS score value (0-10)`,
|
||||
},
|
||||
|
||||
responseId: {
|
||||
sql: `response_id`,
|
||||
type: `string`,
|
||||
description: `Unique identifier linking related feedback records`,
|
||||
},
|
||||
|
||||
userId: {
|
||||
sql: `user_id`,
|
||||
type: `string`,
|
||||
description: `Identifier of the user who provided feedback`,
|
||||
},
|
||||
|
||||
tenantId: {
|
||||
sql: `tenant_id`,
|
||||
type: `string`,
|
||||
description: `Tenant ID linking to FeedbackDirectory`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -15,6 +15,14 @@ Hub resource name: base name truncated to 59 chars then "-hub" so the suffix is
|
||||
{{- printf "%s-hub" $base | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Cube.js resource name.
|
||||
*/}}
|
||||
{{- define "formbricks.cubeName" -}}
|
||||
{{- $base := include "formbricks.name" . | trunc 58 | trimSuffix "-" }}
|
||||
{{- printf "%s-cube" $base | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
{{/*
|
||||
Define the application version to be used in labels.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user