mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 11:28:58 -05:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d77ed04de | |||
| 602ffd5bba | |||
| 037b005d48 | |||
| 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 |
@@ -43,6 +43,7 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -151,7 +152,17 @@ export const MainNavigation = ({
|
||||
},
|
||||
{
|
||||
id: "unify-feedback",
|
||||
name: t("workspace.unify.unify_feedback"),
|
||||
name: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>{t("workspace.unify.unify_feedback")}</span>
|
||||
<Badge
|
||||
text="Beta"
|
||||
type="gray"
|
||||
size="tiny"
|
||||
className="normal-case text-[10px] font-semibold tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
name: t("workspace.unify.feedback_records"),
|
||||
|
||||
+27
-5
@@ -1665,22 +1665,37 @@ checksums:
|
||||
workspace/analysis/charts/failed_to_load_dashboards: 876c54d9cc69ceda6f808231e2557eb2
|
||||
workspace/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
|
||||
workspace/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
|
||||
workspace/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
|
||||
workspace/analysis/charts/field_label_ces_average: 3bf598396ea490f3a2bdccf0c94b6aa0
|
||||
workspace/analysis/charts/field_label_ces_count: 4dc90d50a8e05dd9ba4a9e356926e0cb
|
||||
workspace/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
|
||||
workspace/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
|
||||
workspace/analysis/charts/field_label_created_at: 9ce495d7fc74e1a2ae86c07206a3e531
|
||||
workspace/analysis/charts/field_label_csat_average: f7c43dac56267f832fbebd6d18efdef1
|
||||
workspace/analysis/charts/field_label_csat_count: 30c1ab12748b503ffee399ed326e0562
|
||||
workspace/analysis/charts/field_label_csat_dissatisfied_count: 7f68b2c8302bde5cd93ba86d2163f86d
|
||||
workspace/analysis/charts/field_label_csat_neutral_count: 19edae275784e8d53dd45003a2e8971a
|
||||
workspace/analysis/charts/field_label_csat_satisfied_count: 78fcabc88da4b22171be149b1be509bd
|
||||
workspace/analysis/charts/field_label_csat_score: 89a87f1069641bba10607d5b407cb0aa
|
||||
workspace/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
|
||||
workspace/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
|
||||
workspace/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
|
||||
workspace/analysis/charts/field_label_language: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
workspace/analysis/charts/field_label_nps_average: 4a61877a06cb8e64a0a8375dd058a548
|
||||
workspace/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
|
||||
workspace/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
|
||||
workspace/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
|
||||
workspace/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
|
||||
workspace/analysis/charts/field_label_question: 0576462ce60d4263d7c482463fcc9547
|
||||
workspace/analysis/charts/field_label_question_group: b007e2cfd1262272de3260f8d14d5833
|
||||
workspace/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
|
||||
workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
|
||||
workspace/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
|
||||
workspace/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
|
||||
workspace/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
|
||||
workspace/analysis/charts/field_label_unique_respondents: e340f09af176927f1ed16719ee304274
|
||||
workspace/analysis/charts/field_label_unique_responses: d9ffcc58f72b9fdb143027703371f22b
|
||||
workspace/analysis/charts/field_label_updated_at: a3730393cce5adfd9e50123d96640fd6
|
||||
workspace/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
|
||||
workspace/analysis/charts/field_label_value_boolean: bbdcd3f46954b6304b9069e94e1371ab
|
||||
workspace/analysis/charts/field_label_value_date: c8d705d1975affc01c002324725fec3f
|
||||
workspace/analysis/charts/field_label_value_number: 1f14da79d14bd7b1c2324141f4470675
|
||||
workspace/analysis/charts/field_label_value_text: e097a597cc507c716401ad18255de578
|
||||
workspace/analysis/charts/filter_data: 05cc68ed2896feef60bbe3829cd9063d
|
||||
workspace/analysis/charts/filters: acf5accc113ff3c1992688058576732c
|
||||
workspace/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
|
||||
@@ -3529,6 +3544,12 @@ checksums:
|
||||
workspace/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
|
||||
workspace/unify/enter_value: 4f068bb59617975c1e546218373122cd
|
||||
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
|
||||
workspace/unify/error_connector_field_mapping_duplicate: 4e507ae4a99f53dfa75d849d32566bf2
|
||||
workspace/unify/error_connector_formbricks_mapping_duplicate: 48524e22fa33bd0b2829f7aea49c711b
|
||||
workspace/unify/error_connector_name_duplicate: 56a1a05212e87dff21261d1db90fdb11
|
||||
workspace/unify/error_connector_name_required: b2d5f79f6126e23128d7bef0c1736ff2
|
||||
workspace/unify/error_connector_questions_required: d7d8e388959ab83a9195ba63bebf6516
|
||||
workspace/unify/error_connector_survey_required: 1f49086dfb874307aae1136e88c3d514
|
||||
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
|
||||
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
|
||||
workspace/unify/feedback_directory: 156fa9957e1ee5eadf1f44226a2365e4
|
||||
@@ -3576,6 +3597,7 @@ checksums:
|
||||
workspace/unify/no_feedback_directory_linked_member_description: e799b17da03899cabd125c3cf672c10d
|
||||
workspace/unify/no_feedback_directory_linked_title: b01a53d508aeeb1c8ad080ee07efcd04
|
||||
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
|
||||
workspace/unify/no_formbricks_surveys_available_description: 2218334806910168bdfb6f283f31b963
|
||||
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
|
||||
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
|
||||
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Diagramm konnte nicht gespeichert werden",
|
||||
"field": "Feld",
|
||||
"field_label_average_score": "Durchschnittliche Bewertung",
|
||||
"field_label_ces_average": "CES-Durchschnitt",
|
||||
"field_label_ces_count": "CES-Anzahl",
|
||||
"field_label_collected_at": "Erfasst am",
|
||||
"field_label_count": "Anzahl",
|
||||
"field_label_created_at": "Erstellt am",
|
||||
"field_label_csat_average": "CSAT-Durchschnitt",
|
||||
"field_label_csat_count": "CSAT-Anzahl",
|
||||
"field_label_csat_dissatisfied_count": "CSAT Unzufriedene Anzahl",
|
||||
"field_label_csat_neutral_count": "CSAT Neutrale Anzahl",
|
||||
"field_label_csat_satisfied_count": "CSAT-Zufriedene Anzahl",
|
||||
"field_label_csat_score": "CSAT-Score",
|
||||
"field_label_detractor_count": "Anzahl Kritiker",
|
||||
"field_label_emotion": "Emotion",
|
||||
"field_label_field_type": "Feldtyp",
|
||||
"field_label_language": "Sprache",
|
||||
"field_label_nps_average": "NPS-Durchschnitt",
|
||||
"field_label_nps_score": "NPS-Score",
|
||||
"field_label_nps_value": "NPS-Wert",
|
||||
"field_label_passive_count": "Anzahl Passive",
|
||||
"field_label_promoter_count": "Anzahl Promoter",
|
||||
"field_label_question": "Frage",
|
||||
"field_label_question_group": "Fragengruppe",
|
||||
"field_label_response_id": "Antwort-ID",
|
||||
"field_label_sentiment": "Stimmung",
|
||||
"field_label_source_name": "Quellenname",
|
||||
"field_label_source_type": "Quellentyp",
|
||||
"field_label_topic": "Thema",
|
||||
"field_label_unique_respondents": "Eindeutige Teilnehmer",
|
||||
"field_label_unique_responses": "Eindeutige Antworten",
|
||||
"field_label_updated_at": "Aktualisiert am",
|
||||
"field_label_user_identifier": "Benutzerkennung",
|
||||
"field_label_value_boolean": "Wert (Boolean)",
|
||||
"field_label_value_date": "Wert (Datum)",
|
||||
"field_label_value_number": "Wert (Zahl)",
|
||||
"field_label_value_text": "Wert (Text)",
|
||||
"filter_data": "Daten filtern",
|
||||
"filters": "Filter",
|
||||
"filters_toggle_description": "Nur Daten einbeziehen, die die folgenden Bedingungen erfüllen.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Gib einen Namen für diese Quelle ein",
|
||||
"enter_value": "Wert eingeben...",
|
||||
"enum": "Aufzählung",
|
||||
"error_connector_field_mapping_duplicate": "Doppelte Feldzuordnung für diese Quelle",
|
||||
"error_connector_formbricks_mapping_duplicate": "Doppelte Fragenzuordnung für diese Quelle",
|
||||
"error_connector_name_duplicate": "Eine Quelle mit diesem Namen existiert bereits",
|
||||
"error_connector_name_required": "Quellenname ist erforderlich",
|
||||
"error_connector_questions_required": "Wähle mindestens eine Frage aus",
|
||||
"error_connector_survey_required": "Wähle eine Umfrage aus",
|
||||
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
|
||||
"feedback_date": "Aktuelles Datum",
|
||||
"feedback_directory": "Feedback-Verzeichnis",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Failed to save chart",
|
||||
"field": "Field",
|
||||
"field_label_average_score": "Average Score",
|
||||
"field_label_ces_average": "CES Average",
|
||||
"field_label_ces_count": "CES Count",
|
||||
"field_label_collected_at": "Collected At",
|
||||
"field_label_count": "Count",
|
||||
"field_label_created_at": "Created At",
|
||||
"field_label_csat_average": "CSAT Average",
|
||||
"field_label_csat_count": "CSAT Count",
|
||||
"field_label_csat_dissatisfied_count": "CSAT Dissatisfied Count",
|
||||
"field_label_csat_neutral_count": "CSAT Neutral Count",
|
||||
"field_label_csat_satisfied_count": "CSAT Satisfied Count",
|
||||
"field_label_csat_score": "CSAT Score",
|
||||
"field_label_detractor_count": "Detractor Count",
|
||||
"field_label_emotion": "Emotion",
|
||||
"field_label_field_type": "Field Type",
|
||||
"field_label_language": "Language",
|
||||
"field_label_nps_average": "NPS Average",
|
||||
"field_label_nps_score": "NPS Score",
|
||||
"field_label_nps_value": "NPS Value",
|
||||
"field_label_passive_count": "Passive Count",
|
||||
"field_label_promoter_count": "Promoter Count",
|
||||
"field_label_question": "Question",
|
||||
"field_label_question_group": "Question Group",
|
||||
"field_label_response_id": "Response ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Source Name",
|
||||
"field_label_source_type": "Source Type",
|
||||
"field_label_topic": "Topic",
|
||||
"field_label_unique_respondents": "Unique Respondents",
|
||||
"field_label_unique_responses": "Unique Responses",
|
||||
"field_label_updated_at": "Updated At",
|
||||
"field_label_user_identifier": "User Identifier",
|
||||
"field_label_value_boolean": "Value (Boolean)",
|
||||
"field_label_value_date": "Value (Date)",
|
||||
"field_label_value_number": "Value (Number)",
|
||||
"field_label_value_text": "Value (Text)",
|
||||
"filter_data": "Filter data",
|
||||
"filters": "Filters",
|
||||
"filters_toggle_description": "Only include data that meets the following conditions.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Enter a name for this source",
|
||||
"enter_value": "Enter value...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Duplicate field mapping for this source",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplicate question mapping for this source",
|
||||
"error_connector_name_duplicate": "A source with this name already exists",
|
||||
"error_connector_name_required": "Source name is required",
|
||||
"error_connector_questions_required": "Select at least one question",
|
||||
"error_connector_survey_required": "Select a survey",
|
||||
"failed_to_load_feedback_records": "Failed to load feedback records",
|
||||
"feedback_date": "Current date",
|
||||
"feedback_directory": "Feedback Directory",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Error al guardar el gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Puntuación media",
|
||||
"field_label_ces_average": "Promedio CES",
|
||||
"field_label_ces_count": "Recuento CES",
|
||||
"field_label_collected_at": "Recopilado el",
|
||||
"field_label_count": "Recuento",
|
||||
"field_label_created_at": "Fecha de creación",
|
||||
"field_label_csat_average": "Promedio CSAT",
|
||||
"field_label_csat_count": "Recuento CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Recuento de insatisfechos CSAT",
|
||||
"field_label_csat_neutral_count": "Recuento de neutros CSAT",
|
||||
"field_label_csat_satisfied_count": "Recuento de Satisfechos CSAT",
|
||||
"field_label_csat_score": "Puntuación CSAT",
|
||||
"field_label_detractor_count": "Recuento de detractores",
|
||||
"field_label_emotion": "Emoción",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_language": "Idioma",
|
||||
"field_label_nps_average": "Promedio NPS",
|
||||
"field_label_nps_score": "Puntuación NPS",
|
||||
"field_label_nps_value": "Valor NPS",
|
||||
"field_label_passive_count": "Recuento de pasivos",
|
||||
"field_label_promoter_count": "Recuento de promotores",
|
||||
"field_label_question": "Pregunta",
|
||||
"field_label_question_group": "Grupo de preguntas",
|
||||
"field_label_response_id": "ID de respuesta",
|
||||
"field_label_sentiment": "Sentimiento",
|
||||
"field_label_source_name": "Nombre de origen",
|
||||
"field_label_source_type": "Tipo de origen",
|
||||
"field_label_topic": "Tema",
|
||||
"field_label_unique_respondents": "Encuestados Únicos",
|
||||
"field_label_unique_responses": "Respuestas Únicas",
|
||||
"field_label_updated_at": "Fecha de actualización",
|
||||
"field_label_user_identifier": "Identificador de usuario",
|
||||
"field_label_value_boolean": "Valor (Booleano)",
|
||||
"field_label_value_date": "Valor (Fecha)",
|
||||
"field_label_value_number": "Valor (Número)",
|
||||
"field_label_value_text": "Valor (Texto)",
|
||||
"filter_data": "Filtrar datos",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluye solo los datos que cumplan las siguientes condiciones.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Introduce un nombre para este origen",
|
||||
"enter_value": "Introduce un valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeo de campo duplicado para esta fuente",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeo de pregunta duplicado para esta fuente",
|
||||
"error_connector_name_duplicate": "Ya existe una fuente con este nombre",
|
||||
"error_connector_name_required": "El nombre de origen es obligatorio",
|
||||
"error_connector_questions_required": "Selecciona al menos una pregunta",
|
||||
"error_connector_survey_required": "Selecciona una encuesta",
|
||||
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
|
||||
"feedback_date": "Fecha actual",
|
||||
"feedback_directory": "Directorio de feedback",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Échec de l'enregistrement du graphique",
|
||||
"field": "Champ",
|
||||
"field_label_average_score": "Score moyen",
|
||||
"field_label_ces_average": "Moyenne CES",
|
||||
"field_label_ces_count": "Nombre CES",
|
||||
"field_label_collected_at": "Collecté le",
|
||||
"field_label_count": "Nombre",
|
||||
"field_label_created_at": "Créé le",
|
||||
"field_label_csat_average": "Moyenne CSAT",
|
||||
"field_label_csat_count": "Nombre CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Nombre d'insatisfaits CSAT",
|
||||
"field_label_csat_neutral_count": "Nombre de neutres CSAT",
|
||||
"field_label_csat_satisfied_count": "Nombre de clients satisfaits CSAT",
|
||||
"field_label_csat_score": "Score CSAT",
|
||||
"field_label_detractor_count": "Nombre de détracteurs",
|
||||
"field_label_emotion": "Émotion",
|
||||
"field_label_field_type": "Type de champ",
|
||||
"field_label_language": "Langue",
|
||||
"field_label_nps_average": "Moyenne NPS",
|
||||
"field_label_nps_score": "Score NPS",
|
||||
"field_label_nps_value": "Valeur NPS",
|
||||
"field_label_passive_count": "Nombre de passifs",
|
||||
"field_label_promoter_count": "Nombre de promoteurs",
|
||||
"field_label_question": "Question",
|
||||
"field_label_question_group": "Groupe de questions",
|
||||
"field_label_response_id": "ID de réponse",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Nom de la source",
|
||||
"field_label_source_type": "Type de source",
|
||||
"field_label_topic": "Sujet",
|
||||
"field_label_unique_respondents": "Répondants uniques",
|
||||
"field_label_unique_responses": "Réponses uniques",
|
||||
"field_label_updated_at": "Mis à jour le",
|
||||
"field_label_user_identifier": "Identifiant utilisateur",
|
||||
"field_label_value_boolean": "Valeur (booléenne)",
|
||||
"field_label_value_date": "Valeur (date)",
|
||||
"field_label_value_number": "Valeur (Nombre)",
|
||||
"field_label_value_text": "Valeur (Texte)",
|
||||
"filter_data": "Filtrer les données",
|
||||
"filters": "Filtres",
|
||||
"filters_toggle_description": "Inclure uniquement les données qui répondent aux conditions suivantes.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Entrez un nom pour cette source",
|
||||
"enter_value": "Saisir une valeur...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mappage de champ en double pour cette source",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mappage de question en double pour cette source",
|
||||
"error_connector_name_duplicate": "Une source avec ce nom existe déjà",
|
||||
"error_connector_name_required": "Le nom de la source est requis",
|
||||
"error_connector_questions_required": "Sélectionnez au moins une question",
|
||||
"error_connector_survey_required": "Sélectionnez une enquête",
|
||||
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
|
||||
"feedback_date": "Date actuelle",
|
||||
"feedback_directory": "Répertoire de retours",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "A diagram mentése sikertelen",
|
||||
"field": "Mező",
|
||||
"field_label_average_score": "Átlagos pontszám",
|
||||
"field_label_ces_average": "CES átlag",
|
||||
"field_label_ces_count": "CES darabszám",
|
||||
"field_label_collected_at": "Gyűjtve",
|
||||
"field_label_count": "Darabszám",
|
||||
"field_label_created_at": "Létrehozás dátuma",
|
||||
"field_label_csat_average": "CSAT átlag",
|
||||
"field_label_csat_count": "CSAT darabszám",
|
||||
"field_label_csat_dissatisfied_count": "CSAT elégedetlen válaszok száma",
|
||||
"field_label_csat_neutral_count": "CSAT semleges válaszok száma",
|
||||
"field_label_csat_satisfied_count": "CSAT elégedett válaszadók száma",
|
||||
"field_label_csat_score": "CSAT pontszám",
|
||||
"field_label_detractor_count": "Kritikusok száma",
|
||||
"field_label_emotion": "Érzelem",
|
||||
"field_label_field_type": "Mező típusa",
|
||||
"field_label_language": "Nyelv",
|
||||
"field_label_nps_average": "NPS átlag",
|
||||
"field_label_nps_score": "NPS pontszám",
|
||||
"field_label_nps_value": "NPS érték",
|
||||
"field_label_passive_count": "Passzívak száma",
|
||||
"field_label_promoter_count": "Támogatók száma",
|
||||
"field_label_question": "Kérdés",
|
||||
"field_label_question_group": "Kérdéscsoport",
|
||||
"field_label_response_id": "Válaszazonosító",
|
||||
"field_label_sentiment": "Hangulat",
|
||||
"field_label_source_name": "Forrás neve",
|
||||
"field_label_source_type": "Forrás típusa",
|
||||
"field_label_topic": "Téma",
|
||||
"field_label_unique_respondents": "Egyedi válaszadók",
|
||||
"field_label_unique_responses": "Egyedi válaszok",
|
||||
"field_label_updated_at": "Frissítés dátuma",
|
||||
"field_label_user_identifier": "Felhasználóazonosító",
|
||||
"field_label_value_boolean": "Érték (logikai)",
|
||||
"field_label_value_date": "Érték (dátum)",
|
||||
"field_label_value_number": "Érték (szám)",
|
||||
"field_label_value_text": "Érték (szöveg)",
|
||||
"filter_data": "Adatok szűrése",
|
||||
"filters": "Szűrők",
|
||||
"filters_toggle_description": "Csak azokat az adatokat tartalmazza, amelyek megfelelnek a következő feltételeknek.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Adj nevet ennek a forrásnak",
|
||||
"enter_value": "Érték megadása...",
|
||||
"enum": "felsorolás",
|
||||
"error_connector_field_mapping_duplicate": "Duplikált mezőleképezés ehhez a forráshoz",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplikált kérdésleképezés ehhez a forráshoz",
|
||||
"error_connector_name_duplicate": "Már létezik forrás ezzel a névvel",
|
||||
"error_connector_name_required": "A forrás neve kötelező",
|
||||
"error_connector_questions_required": "Válasszon ki legalább egy kérdést",
|
||||
"error_connector_survey_required": "Válasszon ki egy felmérést",
|
||||
"failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat",
|
||||
"feedback_date": "Aktuális dátum",
|
||||
"feedback_directory": "Visszajelzési könyvtár",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "チャートの保存に失敗しました",
|
||||
"field": "フィールド",
|
||||
"field_label_average_score": "平均スコア",
|
||||
"field_label_ces_average": "CES平均",
|
||||
"field_label_ces_count": "CES件数",
|
||||
"field_label_collected_at": "収集日時",
|
||||
"field_label_count": "カウント",
|
||||
"field_label_created_at": "作成日時",
|
||||
"field_label_csat_average": "CSAT平均",
|
||||
"field_label_csat_count": "CSAT件数",
|
||||
"field_label_csat_dissatisfied_count": "CSAT 不満足数",
|
||||
"field_label_csat_neutral_count": "CSAT 中立数",
|
||||
"field_label_csat_satisfied_count": "CSAT満足件数",
|
||||
"field_label_csat_score": "CSATスコア",
|
||||
"field_label_detractor_count": "批判者数",
|
||||
"field_label_emotion": "感情",
|
||||
"field_label_field_type": "フィールドタイプ",
|
||||
"field_label_language": "言語",
|
||||
"field_label_nps_average": "NPS平均",
|
||||
"field_label_nps_score": "NPSスコア",
|
||||
"field_label_nps_value": "NPS値",
|
||||
"field_label_passive_count": "中立者数",
|
||||
"field_label_promoter_count": "推奨者数",
|
||||
"field_label_question": "質問",
|
||||
"field_label_question_group": "質問グループ",
|
||||
"field_label_response_id": "回答ID",
|
||||
"field_label_sentiment": "感情分析",
|
||||
"field_label_source_name": "ソース名",
|
||||
"field_label_source_type": "ソースタイプ",
|
||||
"field_label_topic": "トピック",
|
||||
"field_label_unique_respondents": "ユニーク回答者数",
|
||||
"field_label_unique_responses": "ユニーク回答数",
|
||||
"field_label_updated_at": "更新日時",
|
||||
"field_label_user_identifier": "ユーザー識別子",
|
||||
"field_label_value_boolean": "値(ブール値)",
|
||||
"field_label_value_date": "値(日付)",
|
||||
"field_label_value_number": "値(数値)",
|
||||
"field_label_value_text": "値(テキスト)",
|
||||
"filter_data": "データをフィルター",
|
||||
"filters": "フィルター",
|
||||
"filters_toggle_description": "以下の条件を満たすデータのみを含めます。",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "このソースの名前を入力",
|
||||
"enter_value": "値を入力...",
|
||||
"enum": "列挙型",
|
||||
"error_connector_field_mapping_duplicate": "このソースのフィールドマッピングが重複しています",
|
||||
"error_connector_formbricks_mapping_duplicate": "このソースの質問マッピングが重複しています",
|
||||
"error_connector_name_duplicate": "この名前のソースは既に存在します",
|
||||
"error_connector_name_required": "ソース名は必須です",
|
||||
"error_connector_questions_required": "少なくとも1つの質問を選択してください",
|
||||
"error_connector_survey_required": "アンケートを選択してください",
|
||||
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
|
||||
"feedback_date": "現在の日付",
|
||||
"feedback_directory": "フィードバックディレクトリ",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Opslaan van diagram mislukt",
|
||||
"field": "Veld",
|
||||
"field_label_average_score": "Gemiddelde score",
|
||||
"field_label_ces_average": "CES Gemiddelde",
|
||||
"field_label_ces_count": "CES Aantal",
|
||||
"field_label_collected_at": "Verzameld op",
|
||||
"field_label_count": "Aantal",
|
||||
"field_label_created_at": "Aangemaakt op",
|
||||
"field_label_csat_average": "CSAT Gemiddelde",
|
||||
"field_label_csat_count": "CSAT Aantal",
|
||||
"field_label_csat_dissatisfied_count": "CSAT Aantal ontevreden",
|
||||
"field_label_csat_neutral_count": "CSAT Aantal neutraal",
|
||||
"field_label_csat_satisfied_count": "CSAT Tevreden Aantal",
|
||||
"field_label_csat_score": "CSAT Score",
|
||||
"field_label_detractor_count": "Aantal detractors",
|
||||
"field_label_emotion": "Emotie",
|
||||
"field_label_field_type": "Veldtype",
|
||||
"field_label_language": "Taal",
|
||||
"field_label_nps_average": "NPS Gemiddelde",
|
||||
"field_label_nps_score": "NPS-score",
|
||||
"field_label_nps_value": "NPS-waarde",
|
||||
"field_label_passive_count": "Aantal passieven",
|
||||
"field_label_promoter_count": "Aantal promoters",
|
||||
"field_label_question": "Vraag",
|
||||
"field_label_question_group": "Vraaggroep",
|
||||
"field_label_response_id": "Antwoord-ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Bronnaam",
|
||||
"field_label_source_type": "Brontype",
|
||||
"field_label_topic": "Onderwerp",
|
||||
"field_label_unique_respondents": "Unieke Respondenten",
|
||||
"field_label_unique_responses": "Unieke Antwoorden",
|
||||
"field_label_updated_at": "Bijgewerkt op",
|
||||
"field_label_user_identifier": "Gebruikersidentificatie",
|
||||
"field_label_value_boolean": "Waarde (Boolean)",
|
||||
"field_label_value_date": "Waarde (Datum)",
|
||||
"field_label_value_number": "Waarde (Getal)",
|
||||
"field_label_value_text": "Waarde (Tekst)",
|
||||
"filter_data": "Data filteren",
|
||||
"filters": "Filters",
|
||||
"filters_toggle_description": "Neem alleen gegevens op die aan de volgende voorwaarden voldoen.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Voer een naam in voor deze bron",
|
||||
"enter_value": "Voer waarde in...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Dubbele veldkoppeling voor deze bron",
|
||||
"error_connector_formbricks_mapping_duplicate": "Dubbele vraagkoppeling voor deze bron",
|
||||
"error_connector_name_duplicate": "Er bestaat al een bron met deze naam",
|
||||
"error_connector_name_required": "Bronnaam is verplicht",
|
||||
"error_connector_questions_required": "Selecteer minimaal één vraag",
|
||||
"error_connector_survey_required": "Selecteer een enquête",
|
||||
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
|
||||
"feedback_date": "Huidige datum",
|
||||
"feedback_directory": "Feedbackmap",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Falha ao salvar gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Pontuação média",
|
||||
"field_label_ces_average": "Média CES",
|
||||
"field_label_ces_count": "Contagem CES",
|
||||
"field_label_collected_at": "Coletado em",
|
||||
"field_label_count": "Contagem",
|
||||
"field_label_created_at": "Criado em",
|
||||
"field_label_csat_average": "Média CSAT",
|
||||
"field_label_csat_count": "Contagem CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Contagem de Insatisfeitos CSAT",
|
||||
"field_label_csat_neutral_count": "Contagem de Neutros CSAT",
|
||||
"field_label_csat_satisfied_count": "Contagem de Satisfeitos CSAT",
|
||||
"field_label_csat_score": "Pontuação CSAT",
|
||||
"field_label_detractor_count": "Contagem de detratores",
|
||||
"field_label_emotion": "Emoção",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_language": "Idioma",
|
||||
"field_label_nps_average": "Média NPS",
|
||||
"field_label_nps_score": "Pontuação de NPS",
|
||||
"field_label_nps_value": "Valor de NPS",
|
||||
"field_label_passive_count": "Contagem de passivos",
|
||||
"field_label_promoter_count": "Contagem de promotores",
|
||||
"field_label_question": "Pergunta",
|
||||
"field_label_question_group": "Grupo de Perguntas",
|
||||
"field_label_response_id": "ID da resposta",
|
||||
"field_label_sentiment": "Sentimento",
|
||||
"field_label_source_name": "Nome da fonte",
|
||||
"field_label_source_type": "Tipo de fonte",
|
||||
"field_label_topic": "Tópico",
|
||||
"field_label_unique_respondents": "Respondentes Únicos",
|
||||
"field_label_unique_responses": "Respostas Únicas",
|
||||
"field_label_updated_at": "Atualizado em",
|
||||
"field_label_user_identifier": "Identificador do usuário",
|
||||
"field_label_value_boolean": "Valor (Booleano)",
|
||||
"field_label_value_date": "Valor (Data)",
|
||||
"field_label_value_number": "Valor (Número)",
|
||||
"field_label_value_text": "Valor (Texto)",
|
||||
"filter_data": "Filtrar dados",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluir apenas dados que atendam às seguintes condições.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Digite um nome para esta origem",
|
||||
"enter_value": "Digite o valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
|
||||
"error_connector_name_duplicate": "Uma fonte com este nome já existe",
|
||||
"error_connector_name_required": "O nome da fonte é obrigatório",
|
||||
"error_connector_questions_required": "Selecione pelo menos uma pergunta",
|
||||
"error_connector_survey_required": "Selecione uma pesquisa",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_directory": "Diretório de Feedback",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Falha ao guardar gráfico",
|
||||
"field": "Campo",
|
||||
"field_label_average_score": "Pontuação média",
|
||||
"field_label_ces_average": "Média CES",
|
||||
"field_label_ces_count": "Contagem CES",
|
||||
"field_label_collected_at": "Recolhido em",
|
||||
"field_label_count": "Contagem",
|
||||
"field_label_created_at": "Criado em",
|
||||
"field_label_csat_average": "Média CSAT",
|
||||
"field_label_csat_count": "Contagem CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Contagem de CSAT Insatisfeito",
|
||||
"field_label_csat_neutral_count": "Contagem de CSAT Neutro",
|
||||
"field_label_csat_satisfied_count": "Contagem de Satisfeitos CSAT",
|
||||
"field_label_csat_score": "Pontuação CSAT",
|
||||
"field_label_detractor_count": "Contagem de detratores",
|
||||
"field_label_emotion": "Emoção",
|
||||
"field_label_field_type": "Tipo de campo",
|
||||
"field_label_language": "Idioma",
|
||||
"field_label_nps_average": "Média NPS",
|
||||
"field_label_nps_score": "Pontuação NPS",
|
||||
"field_label_nps_value": "Valor NPS",
|
||||
"field_label_passive_count": "Contagem de passivos",
|
||||
"field_label_promoter_count": "Contagem de promotores",
|
||||
"field_label_question": "Pergunta",
|
||||
"field_label_question_group": "Grupo de Perguntas",
|
||||
"field_label_response_id": "ID de resposta",
|
||||
"field_label_sentiment": "Sentimento",
|
||||
"field_label_source_name": "Nome da origem",
|
||||
"field_label_source_type": "Tipo de origem",
|
||||
"field_label_topic": "Tópico",
|
||||
"field_label_unique_respondents": "Inquiridos Únicos",
|
||||
"field_label_unique_responses": "Respostas Únicas",
|
||||
"field_label_updated_at": "Atualizado em",
|
||||
"field_label_user_identifier": "Identificador de utilizador",
|
||||
"field_label_value_boolean": "Valor (Booleano)",
|
||||
"field_label_value_date": "Valor (Data)",
|
||||
"field_label_value_number": "Valor (Número)",
|
||||
"field_label_value_text": "Valor (Texto)",
|
||||
"filter_data": "Filtrar dados",
|
||||
"filters": "Filtros",
|
||||
"filters_toggle_description": "Incluir apenas dados que cumpram as seguintes condições.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Introduz um nome para esta origem",
|
||||
"enter_value": "Introduzir valor...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
|
||||
"error_connector_name_duplicate": "Já existe uma origem com este nome",
|
||||
"error_connector_name_required": "O nome da origem é obrigatório",
|
||||
"error_connector_questions_required": "Seleciona pelo menos uma pergunta",
|
||||
"error_connector_survey_required": "Seleciona um inquérito",
|
||||
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
|
||||
"feedback_date": "Data atual",
|
||||
"feedback_directory": "Diretório de Feedback",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Nu s-a putut salva graficul",
|
||||
"field": "Câmp",
|
||||
"field_label_average_score": "Scor mediu",
|
||||
"field_label_ces_average": "Media CES",
|
||||
"field_label_ces_count": "Număr CES",
|
||||
"field_label_collected_at": "Colectat la",
|
||||
"field_label_count": "Număr",
|
||||
"field_label_created_at": "Creat la",
|
||||
"field_label_csat_average": "Media CSAT",
|
||||
"field_label_csat_count": "Număr CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Număr CSAT Nemulțumiți",
|
||||
"field_label_csat_neutral_count": "Număr CSAT Neutri",
|
||||
"field_label_csat_satisfied_count": "Număr clienți mulțumiți CSAT",
|
||||
"field_label_csat_score": "Scor CSAT",
|
||||
"field_label_detractor_count": "Număr de detractori",
|
||||
"field_label_emotion": "Emoție",
|
||||
"field_label_field_type": "Tip câmp",
|
||||
"field_label_language": "Limbă",
|
||||
"field_label_nps_average": "Media NPS",
|
||||
"field_label_nps_score": "Scor NPS",
|
||||
"field_label_nps_value": "Valoare NPS",
|
||||
"field_label_passive_count": "Număr de pasivi",
|
||||
"field_label_promoter_count": "Număr de promotori",
|
||||
"field_label_question": "Întrebare",
|
||||
"field_label_question_group": "Grup de întrebări",
|
||||
"field_label_response_id": "ID răspuns",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Nume sursă",
|
||||
"field_label_source_type": "Tip sursă",
|
||||
"field_label_topic": "Subiect",
|
||||
"field_label_unique_respondents": "Respondenți unici",
|
||||
"field_label_unique_responses": "Răspunsuri unice",
|
||||
"field_label_updated_at": "Actualizat la",
|
||||
"field_label_user_identifier": "Identificator utilizator",
|
||||
"field_label_value_boolean": "Valoare (Boolean)",
|
||||
"field_label_value_date": "Valoare (Dată)",
|
||||
"field_label_value_number": "Valoare (Număr)",
|
||||
"field_label_value_text": "Valoare (Text)",
|
||||
"filter_data": "Filtrează datele",
|
||||
"filters": "Filtre",
|
||||
"filters_toggle_description": "Include doar datele care îndeplinesc următoarele condiții.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Introdu un nume pentru această sursă",
|
||||
"enter_value": "Introdu valoarea...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Mapare duplicată a câmpului pentru această sursă",
|
||||
"error_connector_formbricks_mapping_duplicate": "Mapare duplicată a întrebării pentru această sursă",
|
||||
"error_connector_name_duplicate": "Există deja o sursă cu acest nume",
|
||||
"error_connector_name_required": "Numele sursei este obligatoriu",
|
||||
"error_connector_questions_required": "Selectează cel puțin o întrebare",
|
||||
"error_connector_survey_required": "Selectează un sondaj",
|
||||
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
|
||||
"feedback_date": "Data curentă",
|
||||
"feedback_directory": "Director de Feedback",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Не удалось сохранить график",
|
||||
"field": "Поле",
|
||||
"field_label_average_score": "Средний балл",
|
||||
"field_label_ces_average": "Средний CES",
|
||||
"field_label_ces_count": "Количество CES",
|
||||
"field_label_collected_at": "Дата сбора",
|
||||
"field_label_count": "Количество",
|
||||
"field_label_created_at": "Дата создания",
|
||||
"field_label_csat_average": "Средний CSAT",
|
||||
"field_label_csat_count": "Количество CSAT",
|
||||
"field_label_csat_dissatisfied_count": "Количество недовольных (CSAT)",
|
||||
"field_label_csat_neutral_count": "Количество нейтральных (CSAT)",
|
||||
"field_label_csat_satisfied_count": "Количество удовлетворённых (CSAT)",
|
||||
"field_label_csat_score": "Оценка CSAT",
|
||||
"field_label_detractor_count": "Количество критиков",
|
||||
"field_label_emotion": "Эмоция",
|
||||
"field_label_field_type": "Тип поля",
|
||||
"field_label_language": "Язык",
|
||||
"field_label_nps_average": "Средний NPS",
|
||||
"field_label_nps_score": "Оценка NPS",
|
||||
"field_label_nps_value": "Значение NPS",
|
||||
"field_label_passive_count": "Количество пассивных",
|
||||
"field_label_promoter_count": "Количество промоутеров",
|
||||
"field_label_question": "Вопрос",
|
||||
"field_label_question_group": "Группа вопросов",
|
||||
"field_label_response_id": "ID ответа",
|
||||
"field_label_sentiment": "Тональность",
|
||||
"field_label_source_name": "Название источника",
|
||||
"field_label_source_type": "Тип источника",
|
||||
"field_label_topic": "Тема",
|
||||
"field_label_unique_respondents": "Уникальные респонденты",
|
||||
"field_label_unique_responses": "Уникальные ответы",
|
||||
"field_label_updated_at": "Дата обновления",
|
||||
"field_label_user_identifier": "Идентификатор пользователя",
|
||||
"field_label_value_boolean": "Значение (логическое)",
|
||||
"field_label_value_date": "Значение (дата)",
|
||||
"field_label_value_number": "Значение (число)",
|
||||
"field_label_value_text": "Значение (текст)",
|
||||
"filter_data": "Фильтровать данные",
|
||||
"filters": "Фильтры",
|
||||
"filters_toggle_description": "Включай только те данные, которые соответствуют следующим условиям.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Введи имя для этого источника",
|
||||
"enter_value": "Введите значение...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Дублирующееся сопоставление полей для этого источника",
|
||||
"error_connector_formbricks_mapping_duplicate": "Дублирующееся сопоставление вопросов для этого источника",
|
||||
"error_connector_name_duplicate": "Источник с таким именем уже существует",
|
||||
"error_connector_name_required": "Необходимо указать название источника",
|
||||
"error_connector_questions_required": "Выберите хотя бы один вопрос",
|
||||
"error_connector_survey_required": "Выберите опрос",
|
||||
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
|
||||
"feedback_date": "Текущая дата",
|
||||
"feedback_directory": "Директория обратной связи",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Det gick inte att spara diagrammet",
|
||||
"field": "Fält",
|
||||
"field_label_average_score": "Genomsnittligt betyg",
|
||||
"field_label_ces_average": "CES-medelvärde",
|
||||
"field_label_ces_count": "CES-antal",
|
||||
"field_label_collected_at": "Insamlad",
|
||||
"field_label_count": "Antal",
|
||||
"field_label_created_at": "Skapad",
|
||||
"field_label_csat_average": "CSAT-medelvärde",
|
||||
"field_label_csat_count": "CSAT-antal",
|
||||
"field_label_csat_dissatisfied_count": "CSAT-antal missnöjda",
|
||||
"field_label_csat_neutral_count": "CSAT-antal neutrala",
|
||||
"field_label_csat_satisfied_count": "CSAT antal nöjda",
|
||||
"field_label_csat_score": "CSAT-poäng",
|
||||
"field_label_detractor_count": "Antal kritiker",
|
||||
"field_label_emotion": "Känsla",
|
||||
"field_label_field_type": "Fälttyp",
|
||||
"field_label_language": "Språk",
|
||||
"field_label_nps_average": "NPS-medelvärde",
|
||||
"field_label_nps_score": "NPS-poäng",
|
||||
"field_label_nps_value": "NPS-värde",
|
||||
"field_label_passive_count": "Antal passiva",
|
||||
"field_label_promoter_count": "Antal förespråkare",
|
||||
"field_label_question": "Fråga",
|
||||
"field_label_question_group": "Frågegrupp",
|
||||
"field_label_response_id": "Svar-ID",
|
||||
"field_label_sentiment": "Sentiment",
|
||||
"field_label_source_name": "Källnamn",
|
||||
"field_label_source_type": "Källtyp",
|
||||
"field_label_topic": "Ämne",
|
||||
"field_label_unique_respondents": "Unika respondenter",
|
||||
"field_label_unique_responses": "Unika svar",
|
||||
"field_label_updated_at": "Uppdaterad",
|
||||
"field_label_user_identifier": "Användar-ID",
|
||||
"field_label_value_boolean": "Värde (Boolean)",
|
||||
"field_label_value_date": "Värde (Datum)",
|
||||
"field_label_value_number": "Värde (Nummer)",
|
||||
"field_label_value_text": "Värde (Text)",
|
||||
"filter_data": "Filtrera data",
|
||||
"filters": "Filter",
|
||||
"filters_toggle_description": "Inkludera bara data som uppfyller följande villkor.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Ange ett namn för denna källa",
|
||||
"enter_value": "Ange värde...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Duplicerad fältmappning för denna källa",
|
||||
"error_connector_formbricks_mapping_duplicate": "Duplicerad frågemappning för denna källa",
|
||||
"error_connector_name_duplicate": "En källa med det här namnet finns redan",
|
||||
"error_connector_name_required": "Källnamn krävs",
|
||||
"error_connector_questions_required": "Välj minst en fråga",
|
||||
"error_connector_survey_required": "Välj en undersökning",
|
||||
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
|
||||
"feedback_date": "Aktuellt datum",
|
||||
"feedback_directory": "Feedback-katalog",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "Grafik kaydedilemedi",
|
||||
"field": "Alan",
|
||||
"field_label_average_score": "Ortalama Puan",
|
||||
"field_label_ces_average": "CES Ortalaması",
|
||||
"field_label_ces_count": "CES Sayısı",
|
||||
"field_label_collected_at": "Toplandığı Tarih",
|
||||
"field_label_count": "Sayı",
|
||||
"field_label_created_at": "Oluşturulma Tarihi",
|
||||
"field_label_csat_average": "CSAT Ortalaması",
|
||||
"field_label_csat_count": "CSAT Sayısı",
|
||||
"field_label_csat_dissatisfied_count": "CSAT Memnuniyetsiz Sayısı",
|
||||
"field_label_csat_neutral_count": "CSAT Nötr Sayısı",
|
||||
"field_label_csat_satisfied_count": "CSAT Memnun Sayısı",
|
||||
"field_label_csat_score": "CSAT Puanı",
|
||||
"field_label_detractor_count": "Eleştirmen Sayısı",
|
||||
"field_label_emotion": "Duygu",
|
||||
"field_label_field_type": "Alan Türü",
|
||||
"field_label_language": "Dil",
|
||||
"field_label_nps_average": "NPS Ortalaması",
|
||||
"field_label_nps_score": "NPS Puanı",
|
||||
"field_label_nps_value": "NPS Değeri",
|
||||
"field_label_passive_count": "Pasif Sayısı",
|
||||
"field_label_promoter_count": "Tavsiye Eden Sayısı",
|
||||
"field_label_question": "Soru",
|
||||
"field_label_question_group": "Soru Grubu",
|
||||
"field_label_response_id": "Yanıt Kimliği",
|
||||
"field_label_sentiment": "Duygu Durumu",
|
||||
"field_label_source_name": "Kaynak Adı",
|
||||
"field_label_source_type": "Kaynak Türü",
|
||||
"field_label_topic": "Konu",
|
||||
"field_label_unique_respondents": "Benzersiz Katılımcılar",
|
||||
"field_label_unique_responses": "Benzersiz Yanıtlar",
|
||||
"field_label_updated_at": "Güncellenme Tarihi",
|
||||
"field_label_user_identifier": "Kullanıcı Tanımlayıcısı",
|
||||
"field_label_value_boolean": "Değer (Boolean)",
|
||||
"field_label_value_date": "Değer (Tarih)",
|
||||
"field_label_value_number": "Değer (Sayı)",
|
||||
"field_label_value_text": "Değer (Metin)",
|
||||
"filter_data": "Verileri filtrele",
|
||||
"filters": "Filtreler",
|
||||
"filters_toggle_description": "Yalnızca aşağıdaki koşulları karşılayan verileri dahil et.",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "Bu kaynak için bir ad girin",
|
||||
"enter_value": "Değer girin...",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "Bu kaynak için yinelenen alan eşlemesi",
|
||||
"error_connector_formbricks_mapping_duplicate": "Bu kaynak için yinelenen soru eşlemesi",
|
||||
"error_connector_name_duplicate": "Bu isimde bir kaynak zaten mevcut",
|
||||
"error_connector_name_required": "Kaynak adı gereklidir",
|
||||
"error_connector_questions_required": "En az bir soru seçin",
|
||||
"error_connector_survey_required": "Bir anket seçin",
|
||||
"failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi",
|
||||
"feedback_date": "Geçerli tarih",
|
||||
"feedback_directory": "Geri Bildirim Dizini",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "图表保存失败",
|
||||
"field": "字段",
|
||||
"field_label_average_score": "平均分",
|
||||
"field_label_ces_average": "CES 平均值",
|
||||
"field_label_ces_count": "CES 数量",
|
||||
"field_label_collected_at": "收集时间",
|
||||
"field_label_count": "数量",
|
||||
"field_label_created_at": "创建时间",
|
||||
"field_label_csat_average": "CSAT 平均值",
|
||||
"field_label_csat_count": "CSAT 数量",
|
||||
"field_label_csat_dissatisfied_count": "CSAT 不满意数量",
|
||||
"field_label_csat_neutral_count": "CSAT 中立数量",
|
||||
"field_label_csat_satisfied_count": "CSAT 满意数量",
|
||||
"field_label_csat_score": "CSAT 得分",
|
||||
"field_label_detractor_count": "贬损者数量",
|
||||
"field_label_emotion": "情感",
|
||||
"field_label_field_type": "字段类型",
|
||||
"field_label_language": "语言",
|
||||
"field_label_nps_average": "NPS 平均值",
|
||||
"field_label_nps_score": "NPS 得分",
|
||||
"field_label_nps_value": "NPS 值",
|
||||
"field_label_passive_count": "中立者数量",
|
||||
"field_label_promoter_count": "推荐者数量",
|
||||
"field_label_question": "问题",
|
||||
"field_label_question_group": "问题组",
|
||||
"field_label_response_id": "响应 ID",
|
||||
"field_label_sentiment": "情绪",
|
||||
"field_label_source_name": "来源名称",
|
||||
"field_label_source_type": "来源类型",
|
||||
"field_label_topic": "主题",
|
||||
"field_label_unique_respondents": "独立受访者",
|
||||
"field_label_unique_responses": "独立回复",
|
||||
"field_label_updated_at": "更新时间",
|
||||
"field_label_user_identifier": "用户标识",
|
||||
"field_label_value_boolean": "值(布尔型)",
|
||||
"field_label_value_date": "值(日期)",
|
||||
"field_label_value_number": "数值",
|
||||
"field_label_value_text": "文本值",
|
||||
"filter_data": "筛选数据",
|
||||
"filters": "筛选条件",
|
||||
"filters_toggle_description": "仅包含符合以下条件的数据。",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "为此来源输入名称",
|
||||
"enter_value": "请输入值...",
|
||||
"enum": "枚举",
|
||||
"error_connector_field_mapping_duplicate": "此来源存在重复的字段映射",
|
||||
"error_connector_formbricks_mapping_duplicate": "此来源存在重复的问题映射",
|
||||
"error_connector_name_duplicate": "该名称的数据源已存在",
|
||||
"error_connector_name_required": "数据源名称为必填项",
|
||||
"error_connector_questions_required": "请至少选择一个问题",
|
||||
"error_connector_survey_required": "请选择一个调查问卷",
|
||||
"failed_to_load_feedback_records": "加载反馈记录失败",
|
||||
"feedback_date": "当前日期",
|
||||
"feedback_directory": "反馈目录",
|
||||
|
||||
@@ -1730,22 +1730,37 @@
|
||||
"failed_to_load_dashboards": "Failed to load dashboards",
|
||||
"failed_to_save_chart": "儲存圖表失敗",
|
||||
"field": "欄位",
|
||||
"field_label_average_score": "平均分數",
|
||||
"field_label_ces_average": "CES 平均值",
|
||||
"field_label_ces_count": "CES 數量",
|
||||
"field_label_collected_at": "收集時間",
|
||||
"field_label_count": "數量",
|
||||
"field_label_created_at": "建立時間",
|
||||
"field_label_csat_average": "CSAT 平均值",
|
||||
"field_label_csat_count": "CSAT 數量",
|
||||
"field_label_csat_dissatisfied_count": "CSAT 不滿意數量",
|
||||
"field_label_csat_neutral_count": "CSAT 中立數量",
|
||||
"field_label_csat_satisfied_count": "CSAT 滿意數量",
|
||||
"field_label_csat_score": "CSAT 分數",
|
||||
"field_label_detractor_count": "批評者數量",
|
||||
"field_label_emotion": "情緒",
|
||||
"field_label_field_type": "欄位類型",
|
||||
"field_label_language": "語言",
|
||||
"field_label_nps_average": "NPS 平均值",
|
||||
"field_label_nps_score": "NPS 分數",
|
||||
"field_label_nps_value": "NPS 值",
|
||||
"field_label_passive_count": "中立者數量",
|
||||
"field_label_promoter_count": "推廣者數量",
|
||||
"field_label_question": "問題",
|
||||
"field_label_question_group": "問題群組",
|
||||
"field_label_response_id": "回應 ID",
|
||||
"field_label_sentiment": "情感",
|
||||
"field_label_source_name": "來源名稱",
|
||||
"field_label_source_type": "來源類型",
|
||||
"field_label_topic": "主題",
|
||||
"field_label_unique_respondents": "不重複受訪者",
|
||||
"field_label_unique_responses": "不重複回應",
|
||||
"field_label_updated_at": "更新時間",
|
||||
"field_label_user_identifier": "使用者識別碼",
|
||||
"field_label_value_boolean": "值(布林)",
|
||||
"field_label_value_date": "值(日期)",
|
||||
"field_label_value_number": "數值",
|
||||
"field_label_value_text": "文字值",
|
||||
"filter_data": "篩選資料",
|
||||
"filters": "篩選條件",
|
||||
"filters_toggle_description": "只包含符合下列條件的資料。",
|
||||
@@ -3689,6 +3704,12 @@
|
||||
"enter_name_for_source": "請輸入此來源的名稱",
|
||||
"enter_value": "請輸入值……",
|
||||
"enum": "enum",
|
||||
"error_connector_field_mapping_duplicate": "此來源的欄位對應重複",
|
||||
"error_connector_formbricks_mapping_duplicate": "此來源的問題對應重複",
|
||||
"error_connector_name_duplicate": "已存在使用此名稱的來源",
|
||||
"error_connector_name_required": "來源名稱為必填項目",
|
||||
"error_connector_questions_required": "請至少選擇一個問題",
|
||||
"error_connector_survey_required": "請選擇一個調查問卷",
|
||||
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
|
||||
"feedback_date": "目前日期",
|
||||
"feedback_directory": "意見回饋目錄",
|
||||
|
||||
@@ -187,7 +187,7 @@ describe("executeTenantScopedQuery", () => {
|
||||
...scopedInput,
|
||||
query: {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["secret-value"] }],
|
||||
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("executeTenantScopedQuery", () => {
|
||||
targetType: "cubeQuery",
|
||||
newObject: expect.objectContaining({
|
||||
query: expect.objectContaining({
|
||||
filterMembers: ["FeedbackRecords.sentiment"],
|
||||
filterMembers: ["FeedbackRecords.sourceType"],
|
||||
filterCount: 1,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -107,18 +107,6 @@ describe("cube queryRewrite", () => {
|
||||
).toThrow(/tenant filters are enforced by Cube/);
|
||||
});
|
||||
|
||||
test("rejects caller-supplied TopicsUnnested tenant filters", () => {
|
||||
expect(() =>
|
||||
queryRewrite(
|
||||
{
|
||||
measures: ["TopicsUnnested.count"],
|
||||
filters: [{ member: "TopicsUnnested.tenantId", operator: "equals", values: ["workspace-2"] }],
|
||||
},
|
||||
{ securityContext }
|
||||
)
|
||||
).toThrow(/tenant filters are enforced by Cube/);
|
||||
});
|
||||
|
||||
test("logs sanitized failure audit metadata for rejected tenant filters", () => {
|
||||
expect(() =>
|
||||
queryRewrite(
|
||||
@@ -169,7 +157,7 @@ describe("cube queryRewrite", () => {
|
||||
filters: [
|
||||
{
|
||||
or: [
|
||||
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["workspace-2"] },
|
||||
],
|
||||
},
|
||||
@@ -207,36 +195,23 @@ describe("cube queryRewrite", () => {
|
||||
test("appends the mandatory tenant filter from security context", () => {
|
||||
const query = {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
|
||||
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] }],
|
||||
};
|
||||
|
||||
const rewrittenQuery = queryRewrite(query, { securityContext });
|
||||
|
||||
expect(rewrittenQuery.filters).toEqual([
|
||||
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["frd-1"] },
|
||||
]);
|
||||
expect(query.filters).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("appends only the TopicsUnnested tenant filter for TopicsUnnested queries", () => {
|
||||
const query = {
|
||||
measures: ["TopicsUnnested.count"],
|
||||
dimensions: ["TopicsUnnested.topic"],
|
||||
};
|
||||
|
||||
const rewrittenQuery = queryRewrite(query, { securityContext });
|
||||
|
||||
expect(rewrittenQuery.filters).toEqual([
|
||||
{ member: "TopicsUnnested.tenantId", operator: "equals", values: ["frd-1"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("logs sanitized Cube audit metadata without raw filter values", () => {
|
||||
queryRewrite(
|
||||
{
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["secret-value"] }],
|
||||
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
|
||||
},
|
||||
{ securityContext }
|
||||
);
|
||||
@@ -256,7 +231,6 @@ describe("cube queryRewrite", () => {
|
||||
source: "charts.executeQueryAction",
|
||||
});
|
||||
expect(parsed.members).toContain("FeedbackRecords.tenantId");
|
||||
expect(parsed.members).not.toContain("TopicsUnnested.tenantId");
|
||||
expect(logPayload).not.toContain("secret-value");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ describe("cube-query", () => {
|
||||
expect(() =>
|
||||
validateCubeQueryMembers({
|
||||
measures: ["FeedbackRecords.count"],
|
||||
dimensions: ["FeedbackRecords.sentiment"],
|
||||
dimensions: ["FeedbackRecords.sourceType"],
|
||||
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt" }],
|
||||
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
|
||||
order: { "FeedbackRecords.collectedAt": "desc" },
|
||||
@@ -18,10 +18,6 @@ describe("cube-query", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("allows TopicsUnnested dimensions from joined cube", () => {
|
||||
expect(() => validateCubeQueryMembers({ dimensions: ["TopicsUnnested.topic"] })).not.toThrow();
|
||||
});
|
||||
|
||||
test("throws for invalid members across query sections", () => {
|
||||
expect(() =>
|
||||
validateCubeQueryMembers({
|
||||
@@ -57,7 +53,7 @@ describe("cube-query", () => {
|
||||
filters: [
|
||||
{
|
||||
or: [
|
||||
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] },
|
||||
{
|
||||
and: [{ member: "FeedbackRecords.tenantId", operator: "equals", values: ["workspace-2"] }],
|
||||
},
|
||||
@@ -90,7 +86,7 @@ describe("cube-query", () => {
|
||||
expect(() =>
|
||||
validateCubeQueryMembers({
|
||||
measures: ["FeedbackRecords.count", null],
|
||||
dimensions: [{ member: "FeedbackRecords.sentiment" }],
|
||||
dimensions: [{ member: "FeedbackRecords.sourceType" }],
|
||||
segments: [0],
|
||||
timeDimensions: [null, { dimension: null }],
|
||||
filters: [null, { member: { name: "FeedbackRecords.sourceType" } }, { and: [0] }, { or: "bad" }],
|
||||
@@ -103,7 +99,7 @@ describe("cube-query", () => {
|
||||
test("summarizes query members without raw filter values", () => {
|
||||
const summary = getCubeQueryAuditSummary({
|
||||
measures: ["FeedbackRecords.count"],
|
||||
dimensions: ["FeedbackRecords.sentiment"],
|
||||
dimensions: ["FeedbackRecords.sourceType"],
|
||||
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] }],
|
||||
order: [["FeedbackRecords.collectedAt", "desc"]],
|
||||
limit: 50,
|
||||
@@ -111,7 +107,7 @@ describe("cube-query", () => {
|
||||
|
||||
expect(summary).toEqual({
|
||||
measures: ["FeedbackRecords.count"],
|
||||
dimensions: ["FeedbackRecords.sentiment"],
|
||||
dimensions: ["FeedbackRecords.sourceType"],
|
||||
segments: [],
|
||||
timeDimensions: [],
|
||||
filterMembers: ["FeedbackRecords.sourceType"],
|
||||
@@ -125,12 +121,12 @@ describe("cube-query", () => {
|
||||
test("summarizes only valid member names from malformed query shapes", () => {
|
||||
const summary = getCubeQueryAuditSummary({
|
||||
measures: ["FeedbackRecords.count", null],
|
||||
dimensions: [{ member: "FeedbackRecords.sentiment" }],
|
||||
dimensions: [{ member: "FeedbackRecords.sourceType" }],
|
||||
timeDimensions: [null, { dimension: "FeedbackRecords.collectedAt" }],
|
||||
filters: [
|
||||
null,
|
||||
{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["secret-value"] },
|
||||
{ and: [0, { member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }] },
|
||||
{ and: [0, { member: "FeedbackRecords.sourceType", operator: "equals", values: ["positive"] }] },
|
||||
],
|
||||
order: [
|
||||
[null, "asc"],
|
||||
@@ -143,7 +139,7 @@ describe("cube-query", () => {
|
||||
dimensions: [],
|
||||
segments: [],
|
||||
timeDimensions: ["FeedbackRecords.collectedAt"],
|
||||
filterMembers: ["FeedbackRecords.sentiment", "FeedbackRecords.sourceType"],
|
||||
filterMembers: ["FeedbackRecords.sourceType", "FeedbackRecords.sourceType"],
|
||||
filterCount: 2,
|
||||
orderMembers: ["FeedbackRecords.collectedAt"],
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
|
||||
export const TENANT_MEMBER = "FeedbackRecords.tenantId";
|
||||
|
||||
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords.", "TopicsUnnested."];
|
||||
const ALLOWED_CUBE_PREFIXES = ["FeedbackRecords."];
|
||||
const INVALID_MEMBER_REFERENCE = "invalid member reference";
|
||||
|
||||
type TQueryAuditSummary = {
|
||||
@@ -310,9 +310,9 @@ export const validateCubeQueryMembers = (query: TChartQuery): void => {
|
||||
|
||||
if (result.invalidMembers.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid query members (must start with FeedbackRecords. or TopicsUnnested.): ${uniqueSorted(
|
||||
result.invalidMembers
|
||||
).join(", ")}`
|
||||
`Invalid query members (must start with FeedbackRecords.): ${uniqueSorted(result.invalidMembers).join(
|
||||
", "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("chart Cube actions", () => {
|
||||
mocks.generateText.mockResolvedValue({
|
||||
output: {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
dimensions: ["FeedbackRecords.sentiment"],
|
||||
dimensions: ["FeedbackRecords.sourceType"],
|
||||
timeDimensions: null,
|
||||
chartType: "bar",
|
||||
filters: null,
|
||||
@@ -198,7 +198,7 @@ describe("chart Cube actions", () => {
|
||||
expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith({
|
||||
query: {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
dimensions: ["FeedbackRecords.sentiment"],
|
||||
dimensions: ["FeedbackRecords.sourceType"],
|
||||
},
|
||||
feedbackDirectoryId: "frd-1",
|
||||
workspaceId: "workspace-1",
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
} from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { checkFeedbackDirectoryAccess, checkWorkspaceAccess } from "@/modules/ee/analysis/lib/access";
|
||||
import { generateSchemaContext } from "@/modules/ee/analysis/lib/ai-schema-context";
|
||||
import {
|
||||
FEEDBACK_DIMENSION_IDS,
|
||||
FEEDBACK_MEASURE_IDS,
|
||||
FEEDBACK_TIME_DIMENSION_IDS,
|
||||
} from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { ZChartCreateInput, ZChartType, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsDashboardsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -287,13 +292,25 @@ export const executeQueryAction = authenticatedActionClient
|
||||
|
||||
const CUBE_NAME = "FeedbackRecords";
|
||||
|
||||
const toEnumTuple = (values: readonly string[]): [string, ...string[]] => {
|
||||
if (values.length === 0) {
|
||||
throw new Error("AI query schema requires at least one allowed id");
|
||||
}
|
||||
return [values[0], ...values.slice(1)];
|
||||
};
|
||||
|
||||
const ZMeasureId = z.enum(toEnumTuple(FEEDBACK_MEASURE_IDS));
|
||||
const ZDimensionId = z.enum(toEnumTuple(FEEDBACK_DIMENSION_IDS));
|
||||
const ZTimeDimensionId = z.enum(toEnumTuple(FEEDBACK_TIME_DIMENSION_IDS));
|
||||
const ZFilterMemberId = z.enum(toEnumTuple([...FEEDBACK_MEASURE_IDS, ...FEEDBACK_DIMENSION_IDS]));
|
||||
|
||||
const ZGenerateAIQueryResponse = z.object({
|
||||
measures: z.array(z.string()),
|
||||
dimensions: z.array(z.string()).nullable(),
|
||||
measures: z.array(ZMeasureId),
|
||||
dimensions: z.array(ZDimensionId).nullable(),
|
||||
timeDimensions: z
|
||||
.array(
|
||||
z.object({
|
||||
dimension: z.string(),
|
||||
dimension: ZTimeDimensionId,
|
||||
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).nullable(),
|
||||
dateRange: z.string().nullable(),
|
||||
})
|
||||
@@ -303,7 +320,7 @@ const ZGenerateAIQueryResponse = z.object({
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
member: z.string(),
|
||||
member: ZFilterMemberId,
|
||||
operator: z.enum([
|
||||
"equals",
|
||||
"notEquals",
|
||||
@@ -364,6 +381,7 @@ export const generateAIChartAction = authenticatedActionClient
|
||||
output: Output.object({ schema: ZGenerateAIQueryResponse }),
|
||||
system: schemaContext,
|
||||
prompt: `User request: "${parsedInput.prompt}"`,
|
||||
temperature: 0,
|
||||
});
|
||||
|
||||
const measures = output.measures.length > 0 ? output.measures : [`${CUBE_NAME}.count`];
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { DialogFooter } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartDialogFooterProps {
|
||||
onSaveClick: () => void;
|
||||
onSaveClick?: () => void;
|
||||
formId?: string;
|
||||
onAddToDashboardClick?: () => void;
|
||||
isSaving: boolean;
|
||||
saveLabel?: string;
|
||||
@@ -15,6 +16,7 @@ interface ChartDialogFooterProps {
|
||||
|
||||
export function ChartDialogFooter({
|
||||
onSaveClick,
|
||||
formId,
|
||||
onAddToDashboardClick,
|
||||
isSaving,
|
||||
saveLabel,
|
||||
@@ -24,12 +26,16 @@ export function ChartDialogFooter({
|
||||
return (
|
||||
<DialogFooter>
|
||||
{showAddToDashboard && onAddToDashboardClick && (
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<Button variant="outline" type="button" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSaveClick} disabled={isSaving}>
|
||||
<Button
|
||||
type={formId ? "submit" : "button"}
|
||||
form={formId}
|
||||
onClick={formId ? undefined : onSaveClick}
|
||||
disabled={isSaving}>
|
||||
<SaveIcon className="mr-2 h-4 w-4" />
|
||||
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesia
|
||||
import {
|
||||
CHART_BRAND_DARK,
|
||||
CHART_MEASURE_COLORS,
|
||||
formatCellValue,
|
||||
formatXAxisTick,
|
||||
preparePieData,
|
||||
} from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
@@ -20,6 +21,19 @@ const formatPieLabel = ({ name, percent }: { name: string; percent?: number }):
|
||||
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
|
||||
};
|
||||
|
||||
const PieTooltipRow = ({ value, name }: Readonly<{ value: unknown; name: string }>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{formatCellValue(value)} {formatCubeColumnHeader(name, t)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const pieTooltipFormatter = (value: unknown, name: string | number) => (
|
||||
<PieTooltipRow value={value} name={String(name)} />
|
||||
);
|
||||
|
||||
interface ChartRendererProps {
|
||||
chartType: TChartType;
|
||||
data: TChartDataRow[];
|
||||
@@ -165,13 +179,7 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
|
||||
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
|
||||
})}
|
||||
</Pie>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, name) => [String(value), formatCubeColumnHeader(String(name), t)]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent formatter={pieTooltipFormatter} />} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
@@ -79,6 +80,8 @@ export function CreateChartView({
|
||||
});
|
||||
|
||||
const chartPreviewRef = useRef<HTMLDivElement>(null);
|
||||
const CREATE_CHART_FORM_ID = "create-chart-form";
|
||||
const [chartNameError, setChartNameError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartData) {
|
||||
@@ -136,17 +139,38 @@ export function CreateChartView({
|
||||
<div className="grid gap-4">
|
||||
{hasSelectedDirectory ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<form
|
||||
id={CREATE_CHART_FORM_ID}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setChartNameError(null);
|
||||
return handleSaveChart();
|
||||
}}
|
||||
className="space-y-2">
|
||||
<Label htmlFor="create-chart-name" className={cn(chartNameError && "text-red-500")}>
|
||||
{t("workspace.analysis.charts.chart_name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="create-chart-name"
|
||||
value={chartName}
|
||||
onChange={(event) => setChartName(event.target.value)}
|
||||
onChange={(event) => {
|
||||
if (chartNameError) setChartNameError(null);
|
||||
setChartName(event.target.value);
|
||||
}}
|
||||
onInvalid={(event) => {
|
||||
// Suppress the browser tooltip and render our inline message instead.
|
||||
event.preventDefault();
|
||||
event.currentTarget.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
event.currentTarget.focus();
|
||||
setChartNameError(t("workspace.analysis.charts.please_enter_chart_name"));
|
||||
}}
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
maxLength={255}
|
||||
required
|
||||
isInvalid={!!chartNameError}
|
||||
/>
|
||||
</div>
|
||||
{chartNameError && <p className="text-sm text-red-500">{chartNameError}</p>}
|
||||
</form>
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
@@ -212,7 +236,7 @@ export function CreateChartView({
|
||||
|
||||
{chartData && (
|
||||
<ChartDialogFooter
|
||||
onSaveClick={handleSaveChart}
|
||||
formId={CREATE_CHART_FORM_ID}
|
||||
isSaving={isSaving}
|
||||
showAddToDashboard={false}
|
||||
saveLabel={
|
||||
|
||||
@@ -52,7 +52,7 @@ ${operatorsText}
|
||||
|
||||
## Guidelines
|
||||
- Always include at least one measure. If unspecified, default to \`${CUBE_NAME}.count\`.
|
||||
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sentiment\`, \`FeedbackRecords.collectedAt\`).
|
||||
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sourceType\`, \`FeedbackRecords.collectedAt\`).
|
||||
- For time-based filtering (date range only, no time grouping): add a timeDimension with dimension \`${CUBE_NAME}.collectedAt\` and dateRange. Do NOT include granularity (default is None / filter only).
|
||||
- For time-series or trend questions (e.g. "over time", "by day", "weekly", "monthly"): add a timeDimension with dimension, granularity (hour/day/week/month/quarter/year), and dateRange.
|
||||
- Choose the most appropriate chart type: bar, line, area, pie, or big_number (for single-number queries).
|
||||
|
||||
@@ -22,13 +22,13 @@ describe("query-builder", () => {
|
||||
test("adds dimensions when present", () => {
|
||||
const config: ChartBuilderState = {
|
||||
selectedMeasures: ["FeedbackRecords.count"],
|
||||
selectedDimensions: ["FeedbackRecords.sentiment"],
|
||||
selectedDimensions: ["FeedbackRecords.userId"],
|
||||
filters: [],
|
||||
filterLogic: "and",
|
||||
timeDimension: null,
|
||||
};
|
||||
const query = buildCubeQuery(config);
|
||||
expect(query.dimensions).toEqual(["FeedbackRecords.sentiment"]);
|
||||
expect(query.dimensions).toEqual(["FeedbackRecords.userId"]);
|
||||
});
|
||||
|
||||
test("adds time dimension with string dateRange", () => {
|
||||
@@ -93,7 +93,7 @@ describe("query-builder", () => {
|
||||
selectedMeasures: ["FeedbackRecords.count"],
|
||||
selectedDimensions: [],
|
||||
filters: [
|
||||
{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ id: "f1", field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
|
||||
{ id: "f2", field: "FeedbackRecords.sourceType", operator: "set", values: null },
|
||||
],
|
||||
filterLogic: "and",
|
||||
@@ -101,7 +101,7 @@ describe("query-builder", () => {
|
||||
};
|
||||
const query = buildCubeQuery(config);
|
||||
expect(query.filters).toEqual([
|
||||
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
|
||||
{ member: "FeedbackRecords.sourceType", operator: "set" },
|
||||
]);
|
||||
});
|
||||
@@ -110,14 +110,14 @@ describe("query-builder", () => {
|
||||
const config: ChartBuilderState = {
|
||||
selectedMeasures: ["FeedbackRecords.count"],
|
||||
selectedDimensions: [],
|
||||
filters: [{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
|
||||
filters: [{ id: "f1", field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
|
||||
filterLogic: "or",
|
||||
timeDimension: null,
|
||||
};
|
||||
const query = buildCubeQuery(config);
|
||||
expect(query.filters).toEqual([
|
||||
{
|
||||
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
|
||||
or: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -136,12 +136,12 @@ describe("query-builder", () => {
|
||||
test("parses AND member filters", () => {
|
||||
const query = {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
|
||||
filters: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
|
||||
};
|
||||
const state = parseQueryToState(query);
|
||||
expect(state.filterLogic).toBe("and");
|
||||
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
|
||||
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -150,14 +150,14 @@ describe("query-builder", () => {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
filters: [
|
||||
{
|
||||
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
|
||||
or: [{ member: "FeedbackRecords.userId", operator: "equals", values: ["positive"] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const state = parseQueryToState(query);
|
||||
expect(state.filterLogic).toBe("or");
|
||||
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
|
||||
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
|
||||
{ field: "FeedbackRecords.userId", operator: "equals", values: ["positive"] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -202,7 +202,7 @@ describe("query-builder", () => {
|
||||
test("buildCubeQuery then parseQueryToState restores state", () => {
|
||||
const config: ChartBuilderState = {
|
||||
selectedMeasures: ["FeedbackRecords.count"],
|
||||
selectedDimensions: ["FeedbackRecords.sentiment"],
|
||||
selectedDimensions: ["FeedbackRecords.userId"],
|
||||
filters: [{ id: "f1", field: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
|
||||
filterLogic: "and",
|
||||
timeDimension: {
|
||||
|
||||
@@ -64,10 +64,13 @@ export function buildCubeQuery(config: ChartBuilderState): TChartQuery {
|
||||
timeDim.dateRange = config.timeDimension.dateRange;
|
||||
} else if (Array.isArray(config.timeDimension.dateRange)) {
|
||||
const [startDate, endDate] = config.timeDimension.dateRange;
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const formatDate = (date: Date | string) => {
|
||||
// dateRange round-trips through JSON (saved chart → parseQueryToState), so the array
|
||||
// elements may already be ISO strings — coerce before formatting.
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
|
||||
@@ -137,7 +140,12 @@ export function parseQueryToState(query: TChartQuery): Partial<ChartBuilderState
|
||||
config.granularity = timeDim.granularity;
|
||||
}
|
||||
if (timeDim.dateRange) {
|
||||
config.dateRange = timeDim.dateRange as TimeDimensionConfig["dateRange"];
|
||||
if (typeof timeDim.dateRange === "string") {
|
||||
config.dateRange = timeDim.dateRange;
|
||||
} else if (Array.isArray(timeDim.dateRange) && timeDim.dateRange.length === 2) {
|
||||
// Stored as [isoString, isoString]; lift back into Date objects for the date-picker UI.
|
||||
config.dateRange = [new Date(timeDim.dateRange[0]), new Date(timeDim.dateRange[1])];
|
||||
}
|
||||
}
|
||||
state.timeDimension = config;
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ describe("schema-definition", () => {
|
||||
|
||||
describe("getFieldById", () => {
|
||||
test("returns dimension by id", () => {
|
||||
const field = getFieldById("FeedbackRecords.sentiment");
|
||||
const field = getFieldById("FeedbackRecords.sourceType");
|
||||
expect(field).toBeDefined();
|
||||
expect(field?.label).toBe("Sentiment");
|
||||
expect(field?.label).toBe("Source Type");
|
||||
expect(field?.type).toBe("string");
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("schema-definition", () => {
|
||||
});
|
||||
|
||||
test("returns field label for known dimension/measure", () => {
|
||||
expect(formatCubeColumnHeader("FeedbackRecords.sentiment")).toBe("Sentiment");
|
||||
expect(formatCubeColumnHeader("FeedbackRecords.sourceType")).toBe("Source Type");
|
||||
expect(formatCubeColumnHeader("FeedbackRecords.count")).toBe("Count");
|
||||
});
|
||||
|
||||
@@ -74,5 +74,25 @@ describe("schema-definition", () => {
|
||||
expect(FEEDBACK_FIELDS.dimensions.length).toBeGreaterThan(0);
|
||||
expect(FEEDBACK_FIELDS.measures.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("exposes CSAT, CES, NPS and universal measures", () => {
|
||||
const ids = FEEDBACK_FIELDS.measures.map((m) => m.id);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining([
|
||||
"FeedbackRecords.count",
|
||||
"FeedbackRecords.uniqueRespondents",
|
||||
"FeedbackRecords.uniqueResponses",
|
||||
"FeedbackRecords.npsScore",
|
||||
"FeedbackRecords.npsAverage",
|
||||
"FeedbackRecords.csatScore",
|
||||
"FeedbackRecords.csatAverage",
|
||||
"FeedbackRecords.csatSatisfiedCount",
|
||||
"FeedbackRecords.csatCount",
|
||||
"FeedbackRecords.cesAverage",
|
||||
"FeedbackRecords.cesCount",
|
||||
])
|
||||
);
|
||||
expect(ids).not.toContain("FeedbackRecords.averageScore");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TFunction } from "i18next";
|
||||
export interface FieldDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "string" | "number" | "time";
|
||||
type: "string" | "number" | "time" | "boolean";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -20,12 +20,6 @@ export interface MeasureDefinition {
|
||||
|
||||
export const FEEDBACK_FIELDS = {
|
||||
dimensions: [
|
||||
{
|
||||
id: "FeedbackRecords.sentiment",
|
||||
label: "Sentiment",
|
||||
type: "string",
|
||||
description: "Sentiment extracted from feedback",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.sourceType",
|
||||
label: "Source Type",
|
||||
@@ -45,10 +39,22 @@ export const FEEDBACK_FIELDS = {
|
||||
description: "Type of feedback field (e.g., nps, text, rating)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.emotion",
|
||||
label: "Emotion",
|
||||
id: "FeedbackRecords.fieldLabel",
|
||||
label: "Question",
|
||||
type: "string",
|
||||
description: "Emotion extracted from metadata JSONB field",
|
||||
description: "Human-readable label of the question/field",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.fieldGroupLabel",
|
||||
label: "Question Group",
|
||||
type: "string",
|
||||
description: "Label of the parent composite question for matrix/ranking rows",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.language",
|
||||
label: "Language",
|
||||
type: "string",
|
||||
description: 'Response language code (e.g., "en", "de")',
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.userId",
|
||||
@@ -63,10 +69,30 @@ export const FEEDBACK_FIELDS = {
|
||||
description: "Unique identifier linking related feedback records",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.npsValue",
|
||||
label: "NPS Value",
|
||||
id: "FeedbackRecords.valueNumber",
|
||||
label: "Value (Number)",
|
||||
type: "number",
|
||||
description: "Raw NPS score value (0-10)",
|
||||
description:
|
||||
"Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, number). Pair with a fieldType filter to keep scales consistent.",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.valueText",
|
||||
label: "Value (Text)",
|
||||
type: "string",
|
||||
description:
|
||||
"Text answer value (open text, or the label of a multiple-choice/categorical answer). Pair with a fieldType filter to keep types consistent.",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.valueBoolean",
|
||||
label: "Value (Boolean)",
|
||||
type: "boolean",
|
||||
description: "Boolean answer value (yes/no). Pair with a fieldType filter.",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.valueDate",
|
||||
label: "Value (Date)",
|
||||
type: "time",
|
||||
description: "Date answer value. Pair with a fieldType filter.",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.collectedAt",
|
||||
@@ -75,10 +101,16 @@ export const FEEDBACK_FIELDS = {
|
||||
description: "Timestamp when the feedback was collected",
|
||||
},
|
||||
{
|
||||
id: "TopicsUnnested.topic",
|
||||
label: "Topic",
|
||||
type: "string",
|
||||
description: "Individual topic from the topics array",
|
||||
id: "FeedbackRecords.createdAt",
|
||||
label: "Created At",
|
||||
type: "time",
|
||||
description: "Timestamp when the feedback record was created in Hub",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.updatedAt",
|
||||
label: "Updated At",
|
||||
type: "time",
|
||||
description: "Timestamp when the feedback record was last updated in Hub",
|
||||
},
|
||||
] as FieldDefinition[],
|
||||
measures: [
|
||||
@@ -89,38 +121,106 @@ export const FEEDBACK_FIELDS = {
|
||||
description: "Total number of feedback responses",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.promoterCount",
|
||||
label: "Promoter Count",
|
||||
type: "count",
|
||||
description: "Number of promoters (NPS score 9-10)",
|
||||
id: "FeedbackRecords.uniqueRespondents",
|
||||
label: "Unique Respondents",
|
||||
type: "number",
|
||||
description: "Number of unique users who provided feedback",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.detractorCount",
|
||||
label: "Detractor Count",
|
||||
type: "count",
|
||||
description: "Number of detractors (NPS score 0-6)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.passiveCount",
|
||||
label: "Passive Count",
|
||||
type: "count",
|
||||
description: "Number of passives (NPS score 7-8)",
|
||||
id: "FeedbackRecords.uniqueResponses",
|
||||
label: "Unique Responses",
|
||||
type: "number",
|
||||
description: "Number of unique survey submissions",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.npsScore",
|
||||
label: "NPS Score",
|
||||
type: "number",
|
||||
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
|
||||
description: "Net Promoter Score: ((Promoters - Detractors) / Total NPS responses) * 100",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.averageScore",
|
||||
label: "Average Score",
|
||||
id: "FeedbackRecords.npsAverage",
|
||||
label: "NPS Average",
|
||||
type: "number",
|
||||
description: "Average NPS score",
|
||||
description: "Average NPS rating (0-10)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.promoterCount",
|
||||
label: "Promoter Count",
|
||||
type: "count",
|
||||
description: "Number of NPS promoters (score 9-10)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.passiveCount",
|
||||
label: "Passive Count",
|
||||
type: "count",
|
||||
description: "Number of NPS passives (score 7-8)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.detractorCount",
|
||||
label: "Detractor Count",
|
||||
type: "count",
|
||||
description: "Number of NPS detractors (score 0-6)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatScore",
|
||||
label: "CSAT Score",
|
||||
type: "number",
|
||||
description: "CSAT Score: % of CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatAverage",
|
||||
label: "CSAT Average",
|
||||
type: "number",
|
||||
description: "Average CSAT rating (1-5)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatSatisfiedCount",
|
||||
label: "CSAT Satisfied Count",
|
||||
type: "count",
|
||||
description: "Number of satisfied CSAT responses (top-2-box on the 1-5 scale)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatDissatisfiedCount",
|
||||
label: "CSAT Dissatisfied Count",
|
||||
type: "count",
|
||||
description: "Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatNeutralCount",
|
||||
label: "CSAT Neutral Count",
|
||||
type: "count",
|
||||
description: "Number of neutral CSAT responses (middle box on the 1-5 scale)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.csatCount",
|
||||
label: "CSAT Count",
|
||||
type: "count",
|
||||
description: "Number of CSAT responses",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.cesAverage",
|
||||
label: "CES Average",
|
||||
type: "number",
|
||||
description: "Average CES rating (scale is 1-5 or 1-7 depending on the question)",
|
||||
},
|
||||
{
|
||||
id: "FeedbackRecords.cesCount",
|
||||
label: "CES Count",
|
||||
type: "count",
|
||||
description: "Number of CES responses",
|
||||
},
|
||||
] as MeasureDefinition[],
|
||||
};
|
||||
|
||||
export const FEEDBACK_MEASURE_IDS: string[] = FEEDBACK_FIELDS.measures.map((m) => m.id);
|
||||
|
||||
export const FEEDBACK_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions.map((d) => d.id);
|
||||
|
||||
export const FEEDBACK_TIME_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions
|
||||
.filter((d) => d.type === "time")
|
||||
.map((d) => d.id);
|
||||
|
||||
export type FilterOperator =
|
||||
| "equals"
|
||||
| "notEquals"
|
||||
@@ -137,6 +237,7 @@ export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
|
||||
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
|
||||
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||
boolean: ["equals", "notEquals", "set", "notSet"],
|
||||
};
|
||||
|
||||
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
|
||||
@@ -166,7 +267,7 @@ export const DATE_PRESETS = [
|
||||
/**
|
||||
* Get filter operators for a given field type.
|
||||
*/
|
||||
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
|
||||
export function getFilterOperatorsForType(type: "string" | "number" | "time" | "boolean"): FilterOperator[] {
|
||||
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
|
||||
}
|
||||
|
||||
@@ -184,22 +285,39 @@ export function getFieldById(id: string): FieldDefinition | MeasureDefinition |
|
||||
*/
|
||||
export function getTranslatedFieldLabel(id: string, t: TFunction): string {
|
||||
const labels: Record<string, string> = {
|
||||
"FeedbackRecords.sentiment": t("workspace.analysis.charts.field_label_sentiment"),
|
||||
"FeedbackRecords.sourceType": t("workspace.analysis.charts.field_label_source_type"),
|
||||
"FeedbackRecords.sourceName": t("workspace.analysis.charts.field_label_source_name"),
|
||||
"FeedbackRecords.fieldType": t("workspace.analysis.charts.field_label_field_type"),
|
||||
"FeedbackRecords.emotion": t("workspace.analysis.charts.field_label_emotion"),
|
||||
"FeedbackRecords.fieldLabel": t("workspace.analysis.charts.field_label_question"),
|
||||
"FeedbackRecords.fieldGroupLabel": t("workspace.analysis.charts.field_label_question_group"),
|
||||
"FeedbackRecords.language": t("workspace.analysis.charts.field_label_language"),
|
||||
"FeedbackRecords.userId": t("workspace.analysis.charts.field_label_user_identifier"),
|
||||
"FeedbackRecords.responseId": t("workspace.analysis.charts.field_label_response_id"),
|
||||
"FeedbackRecords.npsValue": t("workspace.analysis.charts.field_label_nps_value"),
|
||||
"FeedbackRecords.valueNumber": t("workspace.analysis.charts.field_label_value_number"),
|
||||
"FeedbackRecords.valueText": t("workspace.analysis.charts.field_label_value_text"),
|
||||
"FeedbackRecords.valueBoolean": t("workspace.analysis.charts.field_label_value_boolean"),
|
||||
"FeedbackRecords.valueDate": t("workspace.analysis.charts.field_label_value_date"),
|
||||
"FeedbackRecords.collectedAt": t("workspace.analysis.charts.field_label_collected_at"),
|
||||
"TopicsUnnested.topic": t("workspace.analysis.charts.field_label_topic"),
|
||||
"FeedbackRecords.createdAt": t("workspace.analysis.charts.field_label_created_at"),
|
||||
"FeedbackRecords.updatedAt": t("workspace.analysis.charts.field_label_updated_at"),
|
||||
"FeedbackRecords.count": t("workspace.analysis.charts.field_label_count"),
|
||||
"FeedbackRecords.promoterCount": t("workspace.analysis.charts.field_label_promoter_count"),
|
||||
"FeedbackRecords.detractorCount": t("workspace.analysis.charts.field_label_detractor_count"),
|
||||
"FeedbackRecords.passiveCount": t("workspace.analysis.charts.field_label_passive_count"),
|
||||
"FeedbackRecords.uniqueRespondents": t("workspace.analysis.charts.field_label_unique_respondents"),
|
||||
"FeedbackRecords.uniqueResponses": t("workspace.analysis.charts.field_label_unique_responses"),
|
||||
"FeedbackRecords.npsScore": t("workspace.analysis.charts.field_label_nps_score"),
|
||||
"FeedbackRecords.averageScore": t("workspace.analysis.charts.field_label_average_score"),
|
||||
"FeedbackRecords.npsAverage": t("workspace.analysis.charts.field_label_nps_average"),
|
||||
"FeedbackRecords.promoterCount": t("workspace.analysis.charts.field_label_promoter_count"),
|
||||
"FeedbackRecords.passiveCount": t("workspace.analysis.charts.field_label_passive_count"),
|
||||
"FeedbackRecords.detractorCount": t("workspace.analysis.charts.field_label_detractor_count"),
|
||||
"FeedbackRecords.csatScore": t("workspace.analysis.charts.field_label_csat_score"),
|
||||
"FeedbackRecords.csatAverage": t("workspace.analysis.charts.field_label_csat_average"),
|
||||
"FeedbackRecords.csatSatisfiedCount": t("workspace.analysis.charts.field_label_csat_satisfied_count"),
|
||||
"FeedbackRecords.csatDissatisfiedCount": t(
|
||||
"workspace.analysis.charts.field_label_csat_dissatisfied_count"
|
||||
),
|
||||
"FeedbackRecords.csatNeutralCount": t("workspace.analysis.charts.field_label_csat_neutral_count"),
|
||||
"FeedbackRecords.csatCount": t("workspace.analysis.charts.field_label_csat_count"),
|
||||
"FeedbackRecords.cesAverage": t("workspace.analysis.charts.field_label_ces_average"),
|
||||
"FeedbackRecords.cesCount": t("workspace.analysis.charts.field_label_ces_count"),
|
||||
};
|
||||
return labels[id] ?? getFieldById(id)?.label ?? id;
|
||||
}
|
||||
|
||||
@@ -289,6 +289,47 @@ describe("FeedbackDirectory Service", () => {
|
||||
).rejects.toThrow(new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG"));
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when a workspace is already assigned to another active directory", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId1,
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
createFeedbackDirectory(mockOrganizationId, "Conflicting", [mockWorkspaceId1])
|
||||
).rejects.toThrow(new InvalidInputError("WORKSPACE_ALREADY_ASSIGNED_TO_DIFFERENT_DIRECTORY"));
|
||||
|
||||
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: { in: [mockWorkspaceId1] },
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
expect(prisma.feedbackDirectory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows creation when workspace is only assigned to archived directory", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.feedbackDirectory.create).mockResolvedValueOnce({
|
||||
id: mockDirectoryId,
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackDirectory(mockOrganizationId, "ArchivedOnly", [mockWorkspaceId1]);
|
||||
|
||||
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: { in: [mockWorkspaceId1] },
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
|
||||
expect(prisma.feedbackDirectory.create).toHaveBeenCalled();
|
||||
expect(result).toBe(mockDirectoryId);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
|
||||
@@ -279,6 +279,7 @@ export const createFeedbackDirectory = async (
|
||||
if (count !== workspaceIds.length) {
|
||||
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
|
||||
}
|
||||
await assertWorkspacesNotAssignedElsewhere(undefined, workspaceIds);
|
||||
}
|
||||
|
||||
const directory = await prisma.feedbackDirectory.create({
|
||||
@@ -440,9 +441,12 @@ const pauseConnectorsInWorkspaces = async (
|
||||
* Enforces the single-active-FRD-per-workspace invariant. The client UI prevents
|
||||
* assigning a workspace to multiple active directories, but the server must also
|
||||
* reject such payloads to keep this guarantee under direct API access.
|
||||
*
|
||||
* Pass `directoryId` when updating an existing directory to exclude it from the
|
||||
* conflict check. Omit it on create — every active directory is a conflict.
|
||||
*/
|
||||
const assertWorkspacesNotAssignedElsewhere = async (
|
||||
directoryId: string,
|
||||
directoryId: string | undefined,
|
||||
workspaceIds: string[]
|
||||
): Promise<void> => {
|
||||
if (workspaceIds.length === 0) return;
|
||||
@@ -450,7 +454,7 @@ const assertWorkspacesNotAssignedElsewhere = async (
|
||||
const conflicting = await prisma.feedbackDirectoryWorkspace.findFirst({
|
||||
where: {
|
||||
workspaceId: { in: workspaceIds },
|
||||
feedbackDirectoryId: { not: directoryId },
|
||||
...(directoryId === undefined ? {} : { feedbackDirectoryId: { not: directoryId } }),
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { TFieldMapping, TUnifySurvey } from "../types";
|
||||
import { TFieldMapping, TUnifySurvey, getTranslatedConnectorError } from "../types";
|
||||
import { ConnectorsTable } from "./connectors-table";
|
||||
import { CreateConnectorModal } from "./create-connector-modal";
|
||||
import { CsvImportModal } from "./csv-import-modal";
|
||||
@@ -28,6 +28,7 @@ interface ConnectorsSectionProps {
|
||||
initialConnectors: TConnectorWithMappings[];
|
||||
initialSurveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function ConnectorsSection({
|
||||
@@ -35,6 +36,7 @@ export function ConnectorsSection({
|
||||
initialConnectors,
|
||||
initialSurveys,
|
||||
directories,
|
||||
isReadOnly,
|
||||
}: Readonly<ConnectorsSectionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -78,7 +80,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -93,7 +95,7 @@ export function ConnectorsSection({
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => {
|
||||
}): Promise<boolean> => {
|
||||
const result = await updateConnectorWithMappingsAction({
|
||||
connectorId: data.connectorId,
|
||||
workspaceId: workspaceId,
|
||||
@@ -111,19 +113,20 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_updated_successfully"));
|
||||
router.refresh();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
|
||||
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,7 +141,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,7 +158,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,11 +173,15 @@ export function ConnectorsSection({
|
||||
<SettingsCard
|
||||
title={t("workspace.unify.feedback_sources")}
|
||||
description={t("workspace.unify.feedback_sources_settings_description")}
|
||||
buttonInfo={{
|
||||
text: t("workspace.unify.add_source"),
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
variant: "default",
|
||||
}}>
|
||||
buttonInfo={
|
||||
isReadOnly
|
||||
? undefined
|
||||
: {
|
||||
text: t("workspace.unify.add_source"),
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
variant: "default",
|
||||
}
|
||||
}>
|
||||
<ConnectorsTable
|
||||
connectors={initialConnectors}
|
||||
onConnectorClick={setEditingConnector}
|
||||
@@ -183,15 +190,18 @@ export function ConnectorsSection({
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={handleDeleteConnector}
|
||||
isLoading={false}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
{directories.length > 0 && (
|
||||
<Alert size="small" className="mt-4">
|
||||
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
{!isReadOnly && (
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsCard>
|
||||
@@ -208,6 +218,7 @@ export function ConnectorsSection({
|
||||
|
||||
<EditConnectorModal
|
||||
connector={editingConnector}
|
||||
isReadOnly={isReadOnly}
|
||||
open={editingConnector !== null}
|
||||
onOpenChange={(open) => !open && setEditingConnector(null)}
|
||||
onUpdateConnector={handleUpdateConnector}
|
||||
|
||||
+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>
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
ZFormbricksConnectorForm,
|
||||
getTranslatedConnectorError,
|
||||
} from "../types";
|
||||
import {
|
||||
TConnectorOptionId,
|
||||
@@ -339,7 +340,12 @@ export const CreateConnectorModal = ({
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
});
|
||||
|
||||
if (connectorId && values.importHistorical) {
|
||||
if (!connectorId) {
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.importHistorical) {
|
||||
await handleHistoricalImport(connectorId, values.surveyId);
|
||||
}
|
||||
|
||||
@@ -368,7 +374,12 @@ export const CreateConnectorModal = ({
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
|
||||
if (connectorId && csvParsedData.length > 0) {
|
||||
if (!connectorId) {
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (csvParsedData.length > 0) {
|
||||
await handleCsvImport(connectorId);
|
||||
}
|
||||
|
||||
@@ -426,11 +437,13 @@ export const CreateConnectorModal = ({
|
||||
|
||||
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={formbricksForm.handleSubmit(handleCreateFormbricksConnector)}>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -440,7 +453,9 @@ export const CreateConnectorModal = ({
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -450,7 +465,7 @@ export const CreateConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -467,7 +482,9 @@ export const CreateConnectorModal = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -475,7 +492,7 @@ export const CreateConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -487,7 +504,9 @@ export const CreateConnectorModal = ({
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
ZFormbricksConnectorForm,
|
||||
getTranslatedConnectorError,
|
||||
} from "../types";
|
||||
import {
|
||||
areAllRequiredFieldsMapped,
|
||||
@@ -59,9 +60,10 @@ interface EditConnectorModalProps {
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<void>;
|
||||
}) => Promise<boolean>;
|
||||
surveys: TUnifySurvey[];
|
||||
onOpenCsvImport?: () => void;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export const EditConnectorModal = ({
|
||||
@@ -71,6 +73,7 @@ export const EditConnectorModal = ({
|
||||
onUpdateConnector,
|
||||
surveys,
|
||||
onOpenCsvImport,
|
||||
isReadOnly = false,
|
||||
}: EditConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
@@ -174,7 +177,7 @@ export const EditConnectorModal = ({
|
||||
const handleUpdateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
|
||||
if (connector?.type !== "formbricks_survey") return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
const success = await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: values.sourceName.trim(),
|
||||
@@ -182,13 +185,15 @@ export const EditConnectorModal = ({
|
||||
fieldMappings: undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
if (success) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCsvConnector = async () => {
|
||||
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
const success = await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: csvConnectorName.trim(),
|
||||
@@ -196,7 +201,9 @@ export const EditConnectorModal = ({
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
if (success) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormbricksQuestionToggle = (questionId: string) => {
|
||||
@@ -239,11 +246,13 @@ export const EditConnectorModal = ({
|
||||
<div className="space-y-4 py-4">
|
||||
{connector.type === "formbricks_survey" ? (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={formbricksForm.handleSubmit(handleUpdateFormbricksConnector)}>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -251,9 +260,12 @@ export const EditConnectorModal = ({
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -261,7 +273,7 @@ export const EditConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -281,7 +293,9 @@ export const EditConnectorModal = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -289,19 +303,21 @@ export const EditConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<fieldset className={isReadOnly ? "opacity-70" : undefined} disabled={isReadOnly}>
|
||||
<FormbricksQuestionList
|
||||
survey={selectedSurvey}
|
||||
selectedQuestionIds={selectedQuestionIds}
|
||||
onQuestionToggle={handleFormbricksQuestionToggle}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -328,41 +344,54 @@ export const EditConnectorModal = ({
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<fieldset
|
||||
disabled={isReadOnly}
|
||||
className={`max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4 ${
|
||||
isReadOnly ? "opacity-70" : ""
|
||||
}`}>
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
connectorType={connector.type}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{connector.type === "csv" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenCsvImport?.();
|
||||
}}>
|
||||
{t("workspace.unify.import_feedback")}
|
||||
{isReadOnly ? (
|
||||
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{connector.type === "csv" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenCsvImport?.();
|
||||
}}>
|
||||
{t("workspace.unify.import_feedback")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -17,8 +17,16 @@ export const WorkspaceFeedbackSourcesPage = async (
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session, organization } =
|
||||
await getWorkspaceAuth(params.workspaceId);
|
||||
const {
|
||||
isOwner,
|
||||
isManager,
|
||||
hasReadAccess,
|
||||
hasReadWriteAccess,
|
||||
hasManageAccess,
|
||||
isReadOnly,
|
||||
session,
|
||||
organization,
|
||||
} = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
@@ -72,6 +80,7 @@ export const WorkspaceFeedbackSourcesPage = async (
|
||||
initialConnectors={connectors}
|
||||
initialSurveys={unifySurveys}
|
||||
directories={directories}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -220,10 +220,29 @@ export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSc
|
||||
export type TCreateConnectorStep = "selectType" | "mapping";
|
||||
|
||||
export const ZFormbricksConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
sourceName: z.string().trim().min(1, "CONNECTOR_NAME_REQUIRED"),
|
||||
surveyId: z.string().min(1, "CONNECTOR_SURVEY_REQUIRED"),
|
||||
selectedQuestionIds: z.array(z.string()).min(1, "CONNECTOR_QUESTIONS_REQUIRED"),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
|
||||
export type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
|
||||
|
||||
export const getTranslatedConnectorError = (errorCode: string, t: TFunction): string => {
|
||||
switch (errorCode) {
|
||||
case "CONNECTOR_NAME_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_name_duplicate");
|
||||
case "CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_formbricks_mapping_duplicate");
|
||||
case "CONNECTOR_FIELD_MAPPING_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_field_mapping_duplicate");
|
||||
case "CONNECTOR_NAME_REQUIRED":
|
||||
return t("workspace.unify.error_connector_name_required");
|
||||
case "CONNECTOR_SURVEY_REQUIRED":
|
||||
return t("workspace.unify.error_connector_survey_required");
|
||||
case "CONNECTOR_QUESTIONS_REQUIRED":
|
||||
return t("workspace.unify.error_connector_questions_required");
|
||||
default:
|
||||
return errorCode;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,7 +126,8 @@ const FormError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const errorMessage = error?.message || error?.root?.message;
|
||||
const body = error ? String(errorMessage) : children;
|
||||
// Explicit children win — they're typically a translated/formatted version of the raw error.
|
||||
const body = children ?? (error ? String(errorMessage) : null);
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -28,6 +28,32 @@ const SelectTrigger = React.forwardRef<
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
|
||||
{...props}>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
|
||||
{...props}>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
@@ -39,18 +65,16 @@ const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = R
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white text-slate-700 shadow-md animate-in fade-in-80 dark:bg-slate-700 dark:text-slate-300",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
"max-h-[var(--radix-select-content-available-height)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}>
|
||||
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
@@ -98,6 +122,8 @@ export {
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-env es2022 */
|
||||
|
||||
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
|
||||
const TENANT_MEMBERS = ["FeedbackRecords.tenantId"];
|
||||
const REQUIRED_SCOPE = "xm:cube:query";
|
||||
|
||||
function assertRequiredEnvironmentVariable(name) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
|
||||
// If the Hub changes column names, types, or the metadata JSONB shape (e.g. the `topics` array),
|
||||
// this schema must be updated to match.
|
||||
// If the Hub changes column names or types, this schema must be updated to match.
|
||||
cube(`FeedbackRecords`, {
|
||||
sql: `SELECT * FROM feedback_records`,
|
||||
|
||||
@@ -60,12 +59,6 @@ cube(`FeedbackRecords`, {
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
sentiment: {
|
||||
sql: `sentiment`,
|
||||
type: `string`,
|
||||
description: `Sentiment extracted from metadata JSONB field`,
|
||||
},
|
||||
|
||||
sourceType: {
|
||||
sql: `source_type`,
|
||||
type: `string`,
|
||||
@@ -108,65 +101,10 @@ cube(`FeedbackRecords`, {
|
||||
description: `Identifier of the user who provided feedback`,
|
||||
},
|
||||
|
||||
emotion: {
|
||||
sql: `emotion`,
|
||||
type: `string`,
|
||||
description: `Emotion extracted from metadata JSONB field`,
|
||||
},
|
||||
|
||||
tenantId: {
|
||||
sql: `tenant_id`,
|
||||
type: `string`,
|
||||
description: `Tenant ID linking to FeedbackDirectory`,
|
||||
},
|
||||
},
|
||||
|
||||
joins: {
|
||||
TopicsUnnested: {
|
||||
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
|
||||
relationship: `hasMany`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
cube(`TopicsUnnested`, {
|
||||
sql: `
|
||||
SELECT
|
||||
fr.id as feedback_record_id,
|
||||
fr.tenant_id,
|
||||
topic_elem.topic
|
||||
FROM feedback_records fr
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
|
||||
`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `md5(feedback_record_id || '::' || topic)`,
|
||||
type: `string`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
feedbackRecordId: {
|
||||
sql: `feedback_record_id`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
tenantId: {
|
||||
sql: `tenant_id`,
|
||||
type: `string`,
|
||||
description: `Tenant ID for row-level security scoping`,
|
||||
},
|
||||
|
||||
topic: {
|
||||
sql: `topic`,
|
||||
type: `string`,
|
||||
description: `Individual topic from the topics array`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -782,9 +782,7 @@ hub:
|
||||
# When empty, the chart renders TEI args from model, servedModelName, port,
|
||||
# revision, and persistence.mountPath. Set this to fully override args.
|
||||
args: []
|
||||
extraArgs:
|
||||
- --dtype
|
||||
- float16
|
||||
extraArgs: []
|
||||
env: {}
|
||||
|
||||
port: 8080
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/* eslint-env es2022 */
|
||||
|
||||
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
|
||||
const TENANT_MEMBERS = ["FeedbackRecords.tenantId"];
|
||||
const REQUIRED_SCOPE = "xm:cube:query";
|
||||
|
||||
function assertRequiredEnvironmentVariable(name) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// This schema maps to the `feedback_records` table owned by the Formbricks Hub Postgres.
|
||||
// If the Hub changes column names, types, or the metadata JSONB shape (e.g. the `topics` array),
|
||||
// this schema must be updated to match.
|
||||
// If the Hub changes column names or types, this schema must be updated to match.
|
||||
cube(`FeedbackRecords`, {
|
||||
sql: `SELECT * FROM feedback_records`,
|
||||
|
||||
@@ -10,46 +9,120 @@ cube(`FeedbackRecords`, {
|
||||
description: `Total number of feedback responses`,
|
||||
},
|
||||
|
||||
uniqueRespondents: {
|
||||
type: `countDistinct`,
|
||||
sql: `${CUBE}.user_id`,
|
||||
description: `Number of unique users who provided feedback`,
|
||||
},
|
||||
|
||||
uniqueResponses: {
|
||||
type: `countDistinct`,
|
||||
sql: `${CUBE}.submission_id`,
|
||||
description: `Number of unique survey submissions (a submission can produce multiple feedback records)`,
|
||||
},
|
||||
|
||||
promoterCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 9` }],
|
||||
description: `Number of promoters (NPS score 9-10)`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9` }],
|
||||
description: `Number of NPS promoters (score 9-10)`,
|
||||
},
|
||||
|
||||
detractorCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6` }],
|
||||
description: `Number of detractors (NPS score 0-6)`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6` }],
|
||||
description: `Number of NPS detractors (score 0-6)`,
|
||||
},
|
||||
|
||||
passiveCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
|
||||
description: `Number of passives (NPS score 7-8)`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 7 AND 8` }],
|
||||
description: `Number of NPS passives (score 7-8)`,
|
||||
},
|
||||
|
||||
npsScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
|
||||
ELSE ROUND(
|
||||
(
|
||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
/ COUNT(*)::numeric
|
||||
(COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number BETWEEN 0 AND 6 THEN 1 END)::numeric)
|
||||
/ COUNT(CASE WHEN ${CUBE}.field_type = 'nps' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Answered NPS responses) * 100. NULL when there are no answered NPS responses.`,
|
||||
},
|
||||
|
||||
averageScore: {
|
||||
npsAverage: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
description: `Average NPS score`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'nps'` }],
|
||||
description: `Average NPS rating (0-10)`,
|
||||
},
|
||||
|
||||
csatCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL` }],
|
||||
description: `Number of answered CSAT responses (dismissed responses excluded).`,
|
||||
},
|
||||
|
||||
csatSatisfiedCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4` }],
|
||||
description: `Number of satisfied CSAT responses (top-2-box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatDissatisfiedCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number BETWEEN 1 AND 2` }],
|
||||
description: `Number of dissatisfied CSAT responses (bottom-2-box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatNeutralCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat' AND ${CUBE}.value_number = 3` }],
|
||||
description: `Number of neutral CSAT responses (middle box on the 1-5 scale)`,
|
||||
},
|
||||
|
||||
csatScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END) = 0 THEN NULL
|
||||
ELSE ROUND(
|
||||
(
|
||||
COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number >= 4 THEN 1 END)::numeric
|
||||
/ COUNT(CASE WHEN ${CUBE}.field_type = 'csat' AND ${CUBE}.value_number IS NOT NULL THEN 1 END)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `CSAT Score: % of answered CSAT responses rated 4 or 5 (top-2-box on the 1-5 scale). NULL when there are no answered CSAT responses.`,
|
||||
},
|
||||
|
||||
csatAverage: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'csat'` }],
|
||||
description: `Average CSAT rating (1-5)`,
|
||||
},
|
||||
|
||||
cesCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'ces' AND ${CUBE}.value_number IS NOT NULL` }],
|
||||
description: `Number of answered CES responses (dismissed responses excluded).`,
|
||||
},
|
||||
|
||||
cesAverage: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
filters: [{ sql: `${CUBE}.field_type = 'ces'` }],
|
||||
description: `Average CES rating (scale is 1-5 or 1-7 depending on the question)`,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -60,12 +133,6 @@ cube(`FeedbackRecords`, {
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
sentiment: {
|
||||
sql: `sentiment`,
|
||||
type: `string`,
|
||||
description: `Sentiment extracted from metadata JSONB field`,
|
||||
},
|
||||
|
||||
sourceType: {
|
||||
sql: `source_type`,
|
||||
type: `string`,
|
||||
@@ -84,22 +151,70 @@ cube(`FeedbackRecords`, {
|
||||
description: `Type of feedback field (e.g., nps, text, rating)`,
|
||||
},
|
||||
|
||||
fieldLabel: {
|
||||
sql: `field_label`,
|
||||
type: `string`,
|
||||
description: `Human-readable label of the question/field (e.g., "How satisfied are you with support?")`,
|
||||
},
|
||||
|
||||
fieldGroupLabel: {
|
||||
sql: `field_group_label`,
|
||||
type: `string`,
|
||||
description: `Label of the parent composite question for matrix/ranking rows`,
|
||||
},
|
||||
|
||||
language: {
|
||||
sql: `language`,
|
||||
type: `string`,
|
||||
description: `Response language code (e.g., "en", "de"). NULL when language is "default".`,
|
||||
},
|
||||
|
||||
collectedAt: {
|
||||
sql: `collected_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback was collected`,
|
||||
},
|
||||
|
||||
npsValue: {
|
||||
createdAt: {
|
||||
sql: `created_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback record was created in Hub`,
|
||||
},
|
||||
|
||||
updatedAt: {
|
||||
sql: `updated_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback record was last updated in Hub`,
|
||||
},
|
||||
|
||||
valueNumber: {
|
||||
sql: `value_number`,
|
||||
type: `number`,
|
||||
description: `Raw NPS score value (0-10)`,
|
||||
description: `Numeric answer value (NPS 0-10, CSAT 1-5, CES 1-5 or 1-7, rating, generic number). Pair with a fieldType filter to keep scales consistent.`,
|
||||
},
|
||||
|
||||
valueText: {
|
||||
sql: `value_text`,
|
||||
type: `string`,
|
||||
description: `Text answer value (open text, or the label of a multiple-choice / categorical answer). Pair with a fieldType filter to keep types consistent.`,
|
||||
},
|
||||
|
||||
valueBoolean: {
|
||||
sql: `value_boolean`,
|
||||
type: `boolean`,
|
||||
description: `Boolean answer value (yes/no questions). Pair with a fieldType filter.`,
|
||||
},
|
||||
|
||||
valueDate: {
|
||||
sql: `value_date`,
|
||||
type: `time`,
|
||||
description: `Date answer value (e.g., "preferred meeting date"). Pair with a fieldType filter.`,
|
||||
},
|
||||
|
||||
responseId: {
|
||||
sql: `response_id`,
|
||||
sql: `submission_id`,
|
||||
type: `string`,
|
||||
description: `Unique identifier linking related feedback records`,
|
||||
description: `Unique identifier linking related feedback records (submission_id in Hub)`,
|
||||
},
|
||||
|
||||
userId: {
|
||||
@@ -108,65 +223,10 @@ cube(`FeedbackRecords`, {
|
||||
description: `Identifier of the user who provided feedback`,
|
||||
},
|
||||
|
||||
emotion: {
|
||||
sql: `emotion`,
|
||||
type: `string`,
|
||||
description: `Emotion extracted from metadata JSONB field`,
|
||||
},
|
||||
|
||||
tenantId: {
|
||||
sql: `tenant_id`,
|
||||
type: `string`,
|
||||
description: `Tenant ID linking to FeedbackDirectory`,
|
||||
},
|
||||
},
|
||||
|
||||
joins: {
|
||||
TopicsUnnested: {
|
||||
sql: `${CUBE}.id = ${TopicsUnnested}.feedback_record_id`,
|
||||
relationship: `hasMany`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
cube(`TopicsUnnested`, {
|
||||
sql: `
|
||||
SELECT
|
||||
fr.id as feedback_record_id,
|
||||
fr.tenant_id,
|
||||
topic_elem.topic
|
||||
FROM feedback_records fr
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(fr.metadata->'topics', '[]'::jsonb)) AS topic_elem(topic)
|
||||
`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `md5(feedback_record_id || '::' || topic)`,
|
||||
type: `string`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
feedbackRecordId: {
|
||||
sql: `feedback_record_id`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
tenantId: {
|
||||
sql: `tenant_id`,
|
||||
type: `string`,
|
||||
description: `Tenant ID for row-level security scoping`,
|
||||
},
|
||||
|
||||
topic: {
|
||||
sql: `topic`,
|
||||
type: `string`,
|
||||
description: `Individual topic from the topics array`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user