mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 03:16:58 -05:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4aabdc674d | |||
| ae9c1e499a | |||
| 5b70c99eb3 | |||
| 10c09f00a8 | |||
| 5f4f133dcb | |||
| 037b005d48 | |||
| ddd2d5e983 | |||
| 6777b284b3 | |||
| c6282632e0 | |||
| f84c409bc4 | |||
| 98b475a2a4 | |||
| c48474b943 | |||
| 3c0d1e3fd7 | |||
| 1f7a496967 | |||
| 99e378ae2e | |||
| c6e39c3103 | |||
| 19472ca9d3 | |||
| 43feff0009 | |||
| 507cec6958 | |||
| d874b65be9 | |||
| c159af1a26 | |||
| 3ce2ef6cf4 | |||
| baae6335c9 | |||
| 8e443db1f6 | |||
| 18cd9afc2c | |||
| 680e1e1593 | |||
| 04bfdeef69 | |||
| 8a1db7b6aa | |||
| 1d22fe2da6 | |||
| d8a119712c | |||
| 50089eeca4 | |||
| d8fe832f5f | |||
| 1c904e2243 | |||
| 4dbecc2d58 | |||
| 72f4e93432 | |||
| 9007502804 | |||
| d84589452c | |||
| 43aaed3923 | |||
| 550bfc6a6c | |||
| 2c22b00ec6 | |||
| d64fb546d3 | |||
| d192fbf839 | |||
| c5d52df9b7 | |||
| 550e859a2d |
@@ -43,6 +43,7 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -151,7 +152,17 @@ export const MainNavigation = ({
|
||||
},
|
||||
{
|
||||
id: "unify-feedback",
|
||||
name: t("workspace.unify.unify_feedback"),
|
||||
name: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>{t("workspace.unify.unify_feedback")}</span>
|
||||
<Badge
|
||||
text="Beta"
|
||||
type="gray"
|
||||
size="tiny"
|
||||
className="normal-case text-[10px] font-semibold tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
name: t("workspace.unify.feedback_records"),
|
||||
|
||||
@@ -1743,6 +1743,7 @@ checksums:
|
||||
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
|
||||
workspace/analysis/charts/update_chart: 7d9223335d9f0c5938ec30356d7034a9
|
||||
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
|
||||
workspace/analysis/dashboards/chart_removed: 1ce20b8ee0b56bcd7d6fea2b5c1ae9fd
|
||||
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
|
||||
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
|
||||
workspace/analysis/dashboards/charts_added_to_dashboard: 917abab80231adf5af51e812352fc77b
|
||||
@@ -3520,6 +3521,9 @@ checksums:
|
||||
workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc
|
||||
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
|
||||
workspace/unify/delete_feedback_record: 86d7262c000cfb1f91ea373036cd3616
|
||||
workspace/unify/delete_feedback_record_confirmation: dd2b12e75cb52a73f92c997c347a8f36
|
||||
workspace/unify/delete_feedback_records_confirmation: cd8a4ba828963fb6dab5c96606565079
|
||||
workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941
|
||||
workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e
|
||||
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
|
||||
@@ -3529,10 +3533,17 @@ 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
|
||||
workspace/unify/feedback_record_created_successfully: 0ff30472085f1313a5ad53837c83e7c1
|
||||
workspace/unify/feedback_record_deleted_successfully: ea58f53841cc48dc974f1589f4718da6
|
||||
workspace/unify/feedback_record_details: 823f3353db049a9d263ef31405054cda
|
||||
workspace/unify/feedback_record_details_description: 0b6f908154161241ce6bdeb4a2acaecd
|
||||
workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
|
||||
@@ -3540,6 +3551,7 @@ checksums:
|
||||
workspace/unify/feedback_record_updated_successfully: cb40ef4b924e21fa627ebe6809d1d826
|
||||
workspace/unify/feedback_record_value_required: b54d4d86f82071a93dc979e8eb359cf0
|
||||
workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388
|
||||
workspace/unify/feedback_records_deleted_successfully: 176c27a362d593a62355904c50bb0e9e
|
||||
workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
|
||||
workspace/unify/feedback_sources: e58ec9be19db8789e7096a756d24f2b2
|
||||
workspace/unify/feedback_sources_directory_access_multiple: 11d613bc1e9825aa6faa3db17ae678eb
|
||||
@@ -3576,6 +3588,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);
|
||||
}
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} Diagramm(e) hinzufügen",
|
||||
"chart_removed": "Diagramm vom Dashboard entfernt",
|
||||
"charts_add_failed": "Diagramme konnten nicht zum Dashboard hinzugefügt werden",
|
||||
"charts_add_partial_failure": "{count} Diagramm(e) konnten nicht hinzugefügt werden",
|
||||
"charts_added_to_dashboard": "Diagramme zum Dashboard hinzugefügt",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Geben Sie den benutzerdefinierten Quelltyp ein",
|
||||
"default_connector_name_csv": "CSV-Import",
|
||||
"default_connector_name_formbricks": "Formbricks-Umfrage-Verbindung",
|
||||
"delete_feedback_record": "Feedback-Eintrag löschen",
|
||||
"delete_feedback_record_confirmation": "Dadurch wird der Feedback-Eintrag dauerhaft gelöscht und aus dem verbundenen Verzeichnis entfernt.",
|
||||
"delete_feedback_records_confirmation": "Dadurch werden {count} Feedback-Einträge dauerhaft gelöscht und aus dem verbundenen Verzeichnis entfernt.",
|
||||
"discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.",
|
||||
"discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?",
|
||||
"drop_a_field_here": "Ziehe ein Feld hierher",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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",
|
||||
"feedback_record_created_successfully": "Feedback-Datensatz erfolgreich erstellt",
|
||||
"feedback_record_deleted_successfully": "Feedback-Eintrag erfolgreich gelöscht",
|
||||
"feedback_record_details": "Details zum Feedback-Datensatz",
|
||||
"feedback_record_details_description": "Überprüfen und aktualisieren Sie die Felder des Feedback-Datensatzes.",
|
||||
"feedback_record_fields": "Feedback-Eintragsfelder",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "Feedback-Datensatz erfolgreich aktualisiert",
|
||||
"feedback_record_value_required": "Für den ausgewählten Feldtyp ist ein Wert erforderlich",
|
||||
"feedback_records": "Feedback-Einträge",
|
||||
"feedback_records_deleted_successfully": "{count} Feedback-Einträge gelöscht",
|
||||
"feedback_records_refreshed": "Feedback-Einträge aktualisiert",
|
||||
"feedback_sources": "Feedback-Quellen",
|
||||
"feedback_sources_directory_access_multiple": "Neue Datensätze aus diesen Quellen werden gespeichert in: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Für diesen Workspace muss ein Feedback-Verzeichnis eingerichtet werden, bevor diese Funktion verfügbar ist. Bitte einen Organisationsinhaber oder Manager, eins zuzuweisen.",
|
||||
"no_feedback_directory_linked_title": "Kein Feedback-Verzeichnis verknüpft",
|
||||
"no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.",
|
||||
"no_formbricks_surveys_available_description": "In diesem Workspace gibt es noch keine Umfragen. <surveyLink>Erstelle eine neue Umfrage</surveyLink>, um eine als Feedback-Quelle zu verwenden.",
|
||||
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
|
||||
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
|
||||
"optional": "Optional",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Add {count} chart(s)",
|
||||
"chart_removed": "Chart removed from dashboard",
|
||||
"charts_add_failed": "Failed to add charts to dashboard",
|
||||
"charts_add_partial_failure": "Failed to add {count} chart(s)",
|
||||
"charts_added_to_dashboard": "Charts added to dashboard",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Enter custom source type",
|
||||
"default_connector_name_csv": "CSV Import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey Connection",
|
||||
"delete_feedback_record": "Delete feedback record",
|
||||
"delete_feedback_record_confirmation": "This will permanently delete the feedback record and remove it from the connected directory.",
|
||||
"delete_feedback_records_confirmation": "This will permanently delete {count} feedback records and remove them from the connected directory.",
|
||||
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
|
||||
"discard_feedback_record_changes_title": "Discard unsaved changes?",
|
||||
"drop_a_field_here": "Drop a field here",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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",
|
||||
"feedback_record_created_successfully": "Feedback record created successfully",
|
||||
"feedback_record_deleted_successfully": "Feedback record deleted successfully",
|
||||
"feedback_record_details": "Feedback record details",
|
||||
"feedback_record_details_description": "Review and update feedback record fields.",
|
||||
"feedback_record_fields": "Feedback Record Fields",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "Feedback record updated successfully",
|
||||
"feedback_record_value_required": "A value is required for the selected field type",
|
||||
"feedback_records": "Feedback Records",
|
||||
"feedback_records_deleted_successfully": "{count} feedback records deleted",
|
||||
"feedback_records_refreshed": "Feedback records refreshed",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "A feedback directory needs to be set up for this workspace before this functionality is available. Ask an organization owner or manager to assign one.",
|
||||
"no_feedback_directory_linked_title": "No feedback directory linked",
|
||||
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
|
||||
"no_formbricks_surveys_available_description": "There are no surveys in this workspace yet. <surveyLink>Create a new survey</surveyLink> to use it as a feedback source.",
|
||||
"no_source_fields_loaded": "No source fields loaded yet",
|
||||
"no_sources_connected": "No sources connected yet. Add a source to get started.",
|
||||
"optional": "Optional",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Añadir {count} gráfico(s)",
|
||||
"chart_removed": "Gráfico eliminado del panel",
|
||||
"charts_add_failed": "Error al añadir gráficos al panel",
|
||||
"charts_add_partial_failure": "Error al añadir {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos añadidos al panel",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Ingrese el tipo de fuente personalizado",
|
||||
"default_connector_name_csv": "Importación CSV",
|
||||
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
|
||||
"delete_feedback_record": "Eliminar registro de comentarios",
|
||||
"delete_feedback_record_confirmation": "Esto eliminará permanentemente el registro de comentarios y lo quitará del directorio conectado.",
|
||||
"delete_feedback_records_confirmation": "Esto eliminará permanentemente {count} registros de comentarios y los quitará del directorio conectado.",
|
||||
"discard_feedback_record_changes_description": "Sus cambios se perderán si cierra este cajón.",
|
||||
"discard_feedback_record_changes_title": "¿Descartar los cambios no guardados?",
|
||||
"drop_a_field_here": "Suelta un campo aquí",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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",
|
||||
"feedback_record_created_successfully": "Registro de comentarios creado correctamente",
|
||||
"feedback_record_deleted_successfully": "Registro de comentarios eliminado correctamente",
|
||||
"feedback_record_details": "Detalles del registro de comentarios",
|
||||
"feedback_record_details_description": "Revise y actualice los campos del registro de comentarios.",
|
||||
"feedback_record_fields": "Campos de registro de comentarios",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "Registro de comentarios actualizado correctamente",
|
||||
"feedback_record_value_required": "Se requiere un valor para el tipo de campo seleccionado",
|
||||
"feedback_records": "Registros de comentarios",
|
||||
"feedback_records_deleted_successfully": "{count} registros de comentarios eliminados",
|
||||
"feedback_records_refreshed": "Registros de comentarios actualizados",
|
||||
"feedback_sources": "Fuentes de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Los nuevos registros de estas fuentes se almacenarán en: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Es necesario configurar un directorio de feedback para este espacio de trabajo antes de que esta funcionalidad esté disponible. Solicita a un propietario o gestor de la organización que asigne uno.",
|
||||
"no_feedback_directory_linked_title": "No hay ningún directorio de feedback vinculado",
|
||||
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
|
||||
"no_formbricks_surveys_available_description": "Todavía no hay encuestas en este espacio de trabajo. <surveyLink>Crea una nueva encuesta</surveyLink> para usar una como fuente de feedback.",
|
||||
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
|
||||
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
|
||||
"optional": "Opcional",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Ajouter {count} graphique(s)",
|
||||
"chart_removed": "Graphique retiré du tableau de bord",
|
||||
"charts_add_failed": "Échec de l'ajout des graphiques au tableau de bord",
|
||||
"charts_add_partial_failure": "Échec de l'ajout de {count} graphique(s)",
|
||||
"charts_added_to_dashboard": "Graphiques ajoutés au tableau de bord",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Entrez le type de source personnalisé",
|
||||
"default_connector_name_csv": "Importation CSV",
|
||||
"default_connector_name_formbricks": "Connexion de sondage Formbricks",
|
||||
"delete_feedback_record": "Supprimer l'enregistrement de commentaire",
|
||||
"delete_feedback_record_confirmation": "Cela supprimera définitivement l'enregistrement de commentaire et le retirera du répertoire connecté.",
|
||||
"delete_feedback_records_confirmation": "Cela supprimera définitivement {count} enregistrements de commentaires et les retirera du répertoire connecté.",
|
||||
"discard_feedback_record_changes_description": "Vos modifications seront perdues si vous fermez ce tiroir.",
|
||||
"discard_feedback_record_changes_title": "Supprimer les modifications non enregistrées ?",
|
||||
"drop_a_field_here": "Déposez un champ ici",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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",
|
||||
"feedback_record_created_successfully": "Enregistrement de commentaires créé avec succès",
|
||||
"feedback_record_deleted_successfully": "Enregistrement de commentaire supprimé avec succès",
|
||||
"feedback_record_details": "Détails de l'enregistrement des commentaires",
|
||||
"feedback_record_details_description": "Examiner et mettre à jour les champs d’enregistrement des commentaires.",
|
||||
"feedback_record_fields": "Champs d'enregistrement de feedback",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "L'enregistrement des commentaires a été mis à jour avec succès",
|
||||
"feedback_record_value_required": "Une valeur est requise pour le type de champ sélectionné",
|
||||
"feedback_records": "Enregistrements de feedback",
|
||||
"feedback_records_deleted_successfully": "{count} enregistrements de commentaires supprimés",
|
||||
"feedback_records_refreshed": "Enregistrements de feedback actualisés",
|
||||
"feedback_sources": "Sources de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Les nouveaux enregistrements de ces sources seront stockés dans : {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Un répertoire de feedback doit être configuré pour cet espace de travail avant que cette fonctionnalité ne soit disponible. Demande à un propriétaire ou un gestionnaire de l'organisation d'en attribuer un.",
|
||||
"no_feedback_directory_linked_title": "Aucun répertoire de feedback lié",
|
||||
"no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.",
|
||||
"no_formbricks_surveys_available_description": "Il n’y a pas encore de sondages dans cet espace de travail. <surveyLink>Créez une nouvelle enquête</surveyLink> pour en utiliser une comme source de feedback.",
|
||||
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
|
||||
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
|
||||
"optional": "Facultatif",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} diagram hozzáadása",
|
||||
"chart_removed": "A diagram eltávolítva a műszerfalról",
|
||||
"charts_add_failed": "A diagramok műszerfalhoz való hozzáadása sikertelen",
|
||||
"charts_add_partial_failure": "{count} diagram hozzáadása sikertelen",
|
||||
"charts_added_to_dashboard": "Diagramok hozzáadva a műszerfalhoz",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Adja meg az egyéni forrástípust",
|
||||
"default_connector_name_csv": "CSV importálás",
|
||||
"default_connector_name_formbricks": "Formbricks kérdőív kapcsolat",
|
||||
"delete_feedback_record": "Visszajelzési bejegyzés törlése",
|
||||
"delete_feedback_record_confirmation": "Ez véglegesen törli a visszajelzési bejegyzést, és eltávolítja a csatlakoztatott könyvtárból.",
|
||||
"delete_feedback_records_confirmation": "Ez véglegesen töröl {count} visszajelzési bejegyzést, és eltávolítja őket a csatlakoztatott könyvtárból.",
|
||||
"discard_feedback_record_changes_description": "A módosítások elvesznek, ha bezárja ezt a fiókot.",
|
||||
"discard_feedback_record_changes_title": "Elveti a nem mentett módosításokat?",
|
||||
"drop_a_field_here": "Húzz ide egy mezőt",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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",
|
||||
"feedback_record_created_successfully": "A visszajelzési rekord sikeresen létrehozva",
|
||||
"feedback_record_deleted_successfully": "A visszajelzési bejegyzés sikeresen törölve",
|
||||
"feedback_record_details": "A visszajelzési rekord részletei",
|
||||
"feedback_record_details_description": "Tekintse át és frissítse a visszajelzési rekordmezőket.",
|
||||
"feedback_record_fields": "Visszajelzési rekord mezők",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "A visszajelzési rekord sikeresen frissítve",
|
||||
"feedback_record_value_required": "A kiválasztott mezőtípushoz értéket kell megadni",
|
||||
"feedback_records": "Visszajelzési rekordok",
|
||||
"feedback_records_deleted_successfully": "{count} visszajelzési bejegyzés törölve",
|
||||
"feedback_records_refreshed": "Visszajelzési rekordok frissítve",
|
||||
"feedback_sources": "Visszajelzési források",
|
||||
"feedback_sources_directory_access_multiple": "Az ezekből a forrásokból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Ehhez a munkaterülethez be kell állítani egy visszajelzési könyvtárat, mielőtt ez a funkció elérhetővé válna. Kérje meg a szervezet tulajdonosát vagy vezetőjét, hogy rendeljen hozzá egyet.",
|
||||
"no_feedback_directory_linked_title": "Nincs visszajelzési könyvtár kapcsolva",
|
||||
"no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.",
|
||||
"no_formbricks_surveys_available_description": "Ebben a munkaterületen még nincsenek kérdőívek. <surveyLink>Hozz létre egy új kérdőívet</surveyLink>, hogy visszajelzési forrásként használhass egyet.",
|
||||
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
|
||||
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
|
||||
"optional": "Elhagyható",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count}個のグラフを追加",
|
||||
"chart_removed": "チャートがダッシュボードから削除されました",
|
||||
"charts_add_failed": "ダッシュボードへのグラフの追加に失敗しました",
|
||||
"charts_add_partial_failure": "{count}個のグラフの追加に失敗しました",
|
||||
"charts_added_to_dashboard": "グラフをダッシュボードに追加しました",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "カスタムソースタイプを入力してください",
|
||||
"default_connector_name_csv": "CSVインポート",
|
||||
"default_connector_name_formbricks": "Formbricks フォーム接続",
|
||||
"delete_feedback_record": "フィードバック記録を削除",
|
||||
"delete_feedback_record_confirmation": "この操作により、フィードバック記録が完全に削除され、接続されているディレクトリから削除されます。",
|
||||
"delete_feedback_records_confirmation": "この操作により、{count}件のフィードバック記録が完全に削除され、接続されているディレクトリから削除されます。",
|
||||
"discard_feedback_record_changes_description": "このドロワーを閉じると、変更内容は失われます。",
|
||||
"discard_feedback_record_changes_title": "保存されていない変更を破棄しますか?",
|
||||
"drop_a_field_here": "ここにフィールドをドロップ",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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": "フィードバックディレクトリ",
|
||||
"feedback_record_created_successfully": "フィードバックレコードが正常に作成されました",
|
||||
"feedback_record_deleted_successfully": "フィードバック記録を削除しました",
|
||||
"feedback_record_details": "フィードバック記録の詳細",
|
||||
"feedback_record_details_description": "フィードバック レコード フィールドを確認して更新します。",
|
||||
"feedback_record_fields": "フィードバックレコードフィールド",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "フィードバックレコードが正常に更新されました",
|
||||
"feedback_record_value_required": "選択したフィールド タイプには値が必要です",
|
||||
"feedback_records": "フィードバックレコード",
|
||||
"feedback_records_deleted_successfully": "{count}件のフィードバック記録を削除しました",
|
||||
"feedback_records_refreshed": "フィードバックレコードを更新しました",
|
||||
"feedback_sources": "フィードバックソース",
|
||||
"feedback_sources_directory_access_multiple": "これらのソースからの新しいレコードは次の場所に保存されます:{directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "この機能を利用するには、このワークスペースにフィードバックディレクトリを設定する必要があります。組織のオーナーまたはマネージャーに割り当てを依頼してください。",
|
||||
"no_feedback_directory_linked_title": "フィードバックディレクトリが未リンク",
|
||||
"no_feedback_records": "フィードバックレコードはまだありません。コネクタがデータの送信を開始すると、ここにレコードが表示されます。",
|
||||
"no_formbricks_surveys_available_description": "このワークスペースにはまだフォームがありません。フィードバックソースとして使用するには<surveyLink>新しいフォームを作成</surveyLink>してください。",
|
||||
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
|
||||
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
|
||||
"optional": "任意",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} grafiek(en) toevoegen",
|
||||
"chart_removed": "Grafiek verwijderd van dashboard",
|
||||
"charts_add_failed": "Grafieken toevoegen aan dashboard mislukt",
|
||||
"charts_add_partial_failure": "{count} grafiek(en) toevoegen mislukt",
|
||||
"charts_added_to_dashboard": "Grafieken toegevoegd aan dashboard",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Voer een aangepast brontype in",
|
||||
"default_connector_name_csv": "CSV import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey verbinding",
|
||||
"delete_feedback_record": "Feedbackrecord verwijderen",
|
||||
"delete_feedback_record_confirmation": "Hiermee wordt het feedbackrecord permanent verwijderd en uit de gekoppelde map gehaald.",
|
||||
"delete_feedback_records_confirmation": "Hiermee worden {count} feedbackrecords permanent verwijderd en uit de gekoppelde map gehaald.",
|
||||
"discard_feedback_record_changes_description": "Als u deze lade sluit, gaan uw wijzigingen verloren.",
|
||||
"discard_feedback_record_changes_title": "Niet-opgeslagen wijzigingen verwijderen?",
|
||||
"drop_a_field_here": "Zet hier een veld neer",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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",
|
||||
"feedback_record_created_successfully": "Feedbackrecord is succesvol aangemaakt",
|
||||
"feedback_record_deleted_successfully": "Feedbackrecord succesvol verwijderd",
|
||||
"feedback_record_details": "Details van feedbackrecord",
|
||||
"feedback_record_details_description": "Controleer en update de feedbackrecordvelden.",
|
||||
"feedback_record_fields": "Feedbackrecordvelden",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "Feedbackrecord is succesvol bijgewerkt",
|
||||
"feedback_record_value_required": "Er is een waarde vereist voor het geselecteerde veldtype",
|
||||
"feedback_records": "Feedbackrecords",
|
||||
"feedback_records_deleted_successfully": "{count} feedbackrecords verwijderd",
|
||||
"feedback_records_refreshed": "Feedbackrecords vernieuwd",
|
||||
"feedback_sources": "Feedbackbronnen",
|
||||
"feedback_sources_directory_access_multiple": "Nieuwe records van deze bronnen worden opgeslagen in: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Er moet eerst een feedbackmap worden ingesteld voor deze werkruimte voordat deze functionaliteit beschikbaar is. Vraag een organisatie-eigenaar of manager om er een toe te wijzen.",
|
||||
"no_feedback_directory_linked_title": "Geen feedbackmap gekoppeld",
|
||||
"no_feedback_records": "Nog geen feedbackrecords. Records verschijnen hier zodra je connectoren gegevens beginnen te verzenden.",
|
||||
"no_formbricks_surveys_available_description": "Er zijn nog geen enquêtes in deze werkruimte. <surveyLink>Maak een nieuwe enquête</surveyLink> om er een als feedbackbron te gebruiken.",
|
||||
"no_source_fields_loaded": "Nog geen bronvelden geladen",
|
||||
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
|
||||
"optional": "Optioneel",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
"chart_removed": "Gráfico removido do painel",
|
||||
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
|
||||
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
|
||||
"default_connector_name_csv": "Importação CSV",
|
||||
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
|
||||
"delete_feedback_record": "Excluir registro de feedback",
|
||||
"delete_feedback_record_confirmation": "Isso excluirá permanentemente o registro de feedback e o removerá do diretório conectado.",
|
||||
"delete_feedback_records_confirmation": "Isso excluirá permanentemente {count} registros de feedback e os removerá do diretório conectado.",
|
||||
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
|
||||
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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",
|
||||
"feedback_record_created_successfully": "Registro de feedback criado com sucesso",
|
||||
"feedback_record_deleted_successfully": "Registro de feedback excluído com sucesso",
|
||||
"feedback_record_details": "Detalhes do registro de feedback",
|
||||
"feedback_record_details_description": "Revise e atualize os campos de registro de feedback.",
|
||||
"feedback_record_fields": "Campos do registro de feedback",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso",
|
||||
"feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado",
|
||||
"feedback_records": "Registros de feedback",
|
||||
"feedback_records_deleted_successfully": "{count} registros de feedback excluídos",
|
||||
"feedback_records_refreshed": "Registros de feedback atualizados",
|
||||
"feedback_sources": "Fontes de Feedback",
|
||||
"feedback_sources_directory_access_multiple": "Novos registros dessas fontes serão armazenados em: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Um diretório de feedback precisa ser configurado para este workspace antes que esta funcionalidade esteja disponível. Peça a um proprietário ou gerente da organização para configurar um.",
|
||||
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
|
||||
"no_feedback_records": "Nenhum registro de feedback ainda. Os registros aparecerão aqui assim que seus conectores começarem a enviar dados.",
|
||||
"no_formbricks_surveys_available_description": "Ainda não há pesquisas neste workspace. <surveyLink>Crie uma nova pesquisa</surveyLink> para usar uma como fonte de feedback.",
|
||||
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
|
||||
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
|
||||
"optional": "Opcional",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adicionar {count} gráfico(s)",
|
||||
"chart_removed": "Gráfico removido do painel",
|
||||
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
|
||||
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
|
||||
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
|
||||
"default_connector_name_csv": "Importação CSV",
|
||||
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
|
||||
"delete_feedback_record": "Eliminar registo de feedback",
|
||||
"delete_feedback_record_confirmation": "Esta ação irá eliminar permanentemente o registo de feedback e removê-lo do diretório associado.",
|
||||
"delete_feedback_records_confirmation": "Esta ação irá eliminar permanentemente {count} registos de feedback e removê-los do diretório associado.",
|
||||
"discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.",
|
||||
"discard_feedback_record_changes_title": "Descartar alterações não salvas?",
|
||||
"drop_a_field_here": "Solte um campo aqui",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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",
|
||||
"feedback_record_created_successfully": "Registro de feedback criado com sucesso",
|
||||
"feedback_record_deleted_successfully": "Registo de feedback eliminado com sucesso",
|
||||
"feedback_record_details": "Detalhes do registro de feedback",
|
||||
"feedback_record_details_description": "Revise e atualize os campos de registro de feedback.",
|
||||
"feedback_record_fields": "Campos de registo de feedback",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso",
|
||||
"feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado",
|
||||
"feedback_records": "Registos de feedback",
|
||||
"feedback_records_deleted_successfully": "{count} registos de feedback eliminados",
|
||||
"feedback_records_refreshed": "Registos de feedback atualizados",
|
||||
"feedback_sources": "Fontes de Feedback",
|
||||
"feedback_sources_directory_access_multiple": "Novos registos destas fontes serão armazenados em: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "É necessário configurar um diretório de feedback para este workspace antes de esta funcionalidade estar disponível. Pede a um proprietário ou gestor da organização para atribuir um.",
|
||||
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
|
||||
"no_feedback_records": "Ainda não há registos de feedback. Os registos aparecerão aqui assim que os teus conectores começarem a enviar dados.",
|
||||
"no_formbricks_surveys_available_description": "Ainda não há inquéritos neste workspace. <surveyLink>Cria um novo inquérito</surveyLink> para usar um como fonte de feedback.",
|
||||
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
|
||||
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
|
||||
"optional": "Opcional",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Adaugă {count} grafic(e)",
|
||||
"chart_removed": "Graficul a fost eliminat din tabloul de bord",
|
||||
"charts_add_failed": "Nu s-au putut adăuga graficele la panoul de control",
|
||||
"charts_add_partial_failure": "Nu s-au putut adăuga {count} grafic(e)",
|
||||
"charts_added_to_dashboard": "Grafice adăugate la panoul de control",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Introduceți tipul de sursă personalizat",
|
||||
"default_connector_name_csv": "Import CSV",
|
||||
"default_connector_name_formbricks": "Conexiune chestionar Formbricks",
|
||||
"delete_feedback_record": "Șterge înregistrarea de feedback",
|
||||
"delete_feedback_record_confirmation": "Aceasta va șterge definitiv înregistrarea de feedback și o va elimina din directorul conectat.",
|
||||
"delete_feedback_records_confirmation": "Aceasta va șterge definitiv {count} înregistrări de feedback și le va elimina din directorul conectat.",
|
||||
"discard_feedback_record_changes_description": "Modificările dvs. se vor pierde dacă închideți acest sertar.",
|
||||
"discard_feedback_record_changes_title": "Renunțați la modificările nesalvate?",
|
||||
"drop_a_field_here": "Trage un câmp aici",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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",
|
||||
"feedback_record_created_successfully": "Înregistrare de feedback creată cu succes",
|
||||
"feedback_record_deleted_successfully": "Înregistrarea de feedback a fost ștearsă cu succes",
|
||||
"feedback_record_details": "Detaliile înregistrării feedback-ului",
|
||||
"feedback_record_details_description": "Examinați și actualizați câmpurile pentru înregistrarea de feedback.",
|
||||
"feedback_record_fields": "Câmpuri înregistrare feedback",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "Înregistrarea feedback-ului a fost actualizată cu succes",
|
||||
"feedback_record_value_required": "Este necesară o valoare pentru tipul de câmp selectat",
|
||||
"feedback_records": "Înregistrări de feedback",
|
||||
"feedback_records_deleted_successfully": "{count} înregistrări de feedback șterse",
|
||||
"feedback_records_refreshed": "Înregistrările de feedback au fost actualizate",
|
||||
"feedback_sources": "Surse de feedback",
|
||||
"feedback_sources_directory_access_multiple": "Înregistrările noi din aceste surse vor fi stocate în: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Trebuie configurat un director de feedback pentru acest spațiu de lucru înainte ca această funcționalitate să fie disponibilă. Solicită unui proprietar sau manager al organizației să atribuie unul.",
|
||||
"no_feedback_directory_linked_title": "Niciun director de feedback conectat",
|
||||
"no_feedback_records": "Nu există încă înregistrări de feedback. Înregistrările vor apărea aici după ce conectorii tăi vor începe să trimită date.",
|
||||
"no_formbricks_surveys_available_description": "Nu există încă chestionare în acest spațiu de lucru. <surveyLink>Creează un chestionar nou</surveyLink> pentru a folosi unul ca sursă de feedback.",
|
||||
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
|
||||
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
|
||||
"optional": "Opțional",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Добавить {count} график(ов)",
|
||||
"chart_removed": "График удалён с панели",
|
||||
"charts_add_failed": "Не удалось добавить графики на дашборд",
|
||||
"charts_add_partial_failure": "Не удалось добавить {count} график(ов)",
|
||||
"charts_added_to_dashboard": "Графики добавлены на дашборд",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Введите собственный тип источника",
|
||||
"default_connector_name_csv": "Импорт CSV",
|
||||
"default_connector_name_formbricks": "Подключение опроса Formbricks",
|
||||
"delete_feedback_record": "Удалить запись обратной связи",
|
||||
"delete_feedback_record_confirmation": "Это навсегда удалит запись обратной связи и уберёт её из подключённого каталога.",
|
||||
"delete_feedback_records_confirmation": "Это навсегда удалит {count} {count, plural, one {запись обратной связи} few {записи обратной связи} many {записей обратной связи} other {записей обратной связи}} и уберёт их из подключённого каталога.",
|
||||
"discard_feedback_record_changes_description": "Ваши изменения будут потеряны, если вы закроете этот ящик.",
|
||||
"discard_feedback_record_changes_title": "Отменить несохраненные изменения?",
|
||||
"drop_a_field_here": "Перетащи сюда поле",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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": "Директория обратной связи",
|
||||
"feedback_record_created_successfully": "Запись отзыва успешно создана",
|
||||
"feedback_record_deleted_successfully": "Запись обратной связи успешно удалена",
|
||||
"feedback_record_details": "Детали записи обратной связи",
|
||||
"feedback_record_details_description": "Просмотрите и обновите поля записи отзыва.",
|
||||
"feedback_record_fields": "Поля записи отзыва",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "Запись отзыва успешно обновлена.",
|
||||
"feedback_record_value_required": "Требуется значение для выбранного типа поля.",
|
||||
"feedback_records": "Записи отзывов",
|
||||
"feedback_records_deleted_successfully": "Удалено {count} {count, plural, one {запись обратной связи} few {записи обратной связи} many {записей обратной связи} other {записей обратной связи}}",
|
||||
"feedback_records_refreshed": "Записи отзывов обновлены",
|
||||
"feedback_sources": "Источники обратной связи",
|
||||
"feedback_sources_directory_access_multiple": "Новые записи из этих источников будут сохранены в: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Для этого рабочего пространства нужно настроить директорию обратной связи, прежде чем эта функция станет доступна. Попроси владельца или менеджера организации назначить её.",
|
||||
"no_feedback_directory_linked_title": "Директория обратной связи не привязана",
|
||||
"no_feedback_records": "Пока нет записей отзывов. Они появятся здесь, когда коннекторы начнут отправлять данные.",
|
||||
"no_formbricks_surveys_available_description": "В этом рабочем пространстве пока нет опросов. <surveyLink>Создайте новый опрос</surveyLink>, чтобы использовать один как источник обратной связи.",
|
||||
"no_source_fields_loaded": "Поля источника ещё не загружены",
|
||||
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
|
||||
"optional": "Необязательно",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Lägg till {count} diagram",
|
||||
"chart_removed": "Diagram borttaget från instrumentpanelen",
|
||||
"charts_add_failed": "Misslyckades med att lägga till diagram i instrumentpanelen",
|
||||
"charts_add_partial_failure": "Misslyckades med att lägga till {count} diagram",
|
||||
"charts_added_to_dashboard": "Diagram tillagda i instrumentpanelen",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Ange anpassad källtyp",
|
||||
"default_connector_name_csv": "CSV-import",
|
||||
"default_connector_name_formbricks": "Formbricks Survey-anslutning",
|
||||
"delete_feedback_record": "Ta bort feedbackpost",
|
||||
"delete_feedback_record_confirmation": "Detta kommer permanent ta bort feedbackposten och radera den från den anslutna katalogen.",
|
||||
"delete_feedback_records_confirmation": "Detta kommer permanent ta bort {count} feedbackposter och radera dem från den anslutna katalogen.",
|
||||
"discard_feedback_record_changes_description": "Dina ändringar kommer att gå förlorade om du stänger den här lådan.",
|
||||
"discard_feedback_record_changes_title": "Vill du ignorera osparade ändringar?",
|
||||
"drop_a_field_here": "Släpp ett fält här",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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",
|
||||
"feedback_record_created_successfully": "Feedbackposten har skapats",
|
||||
"feedback_record_deleted_successfully": "Feedbackposten har tagits bort",
|
||||
"feedback_record_details": "Feedbackpostdetaljer",
|
||||
"feedback_record_details_description": "Granska och uppdatera fält för feedbackposter.",
|
||||
"feedback_record_fields": "Fält för feedbackpost",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "Feedbackposten har uppdaterats",
|
||||
"feedback_record_value_required": "Ett värde krävs för den valda fälttypen",
|
||||
"feedback_records": "Feedbackposter",
|
||||
"feedback_records_deleted_successfully": "{count} feedbackposter har tagits bort",
|
||||
"feedback_records_refreshed": "Feedbackposter har uppdaterats",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "En feedbackkatalog måste konfigureras för denna arbetsyta innan den här funktionen blir tillgänglig. Be en organisationsägare eller chef att tilldela en.",
|
||||
"no_feedback_directory_linked_title": "Ingen feedbackkatalog länkad",
|
||||
"no_feedback_records": "Inga feedbackposter ännu. Poster visas här när dina connectors börjar skicka data.",
|
||||
"no_formbricks_surveys_available_description": "Det finns inga enkäter i denna arbetsyta ännu. <surveyLink>Skapa en ny enkät</surveyLink> för att använda en som feedbackkälla.",
|
||||
"no_source_fields_loaded": "Inga källfält har laddats än",
|
||||
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
|
||||
"optional": "Valfritt",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "{count} grafik ekle",
|
||||
"chart_removed": "Grafik gösterge panosundan kaldırıldı",
|
||||
"charts_add_failed": "Grafikler panoya eklenemedi",
|
||||
"charts_add_partial_failure": "{count} grafik eklenemedi",
|
||||
"charts_added_to_dashboard": "Grafikler panoya eklendi",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "Özel kaynak türünü girin",
|
||||
"default_connector_name_csv": "CSV İçe Aktarma",
|
||||
"default_connector_name_formbricks": "Formbricks Anket Bağlantısı",
|
||||
"delete_feedback_record": "Geri bildirim kaydını sil",
|
||||
"delete_feedback_record_confirmation": "Bu işlem geri bildirim kaydını kalıcı olarak silecek ve bağlı dizinden kaldıracaktır.",
|
||||
"delete_feedback_records_confirmation": "Bu işlem {count} geri bildirim kaydını kalıcı olarak silecek ve bağlı dizinden kaldıracaktır.",
|
||||
"discard_feedback_record_changes_description": "Bu çekmeceyi kapatırsanız değişiklikleriniz kaybolacak.",
|
||||
"discard_feedback_record_changes_title": "Kaydedilmemiş değişiklikler silinsin mi?",
|
||||
"drop_a_field_here": "Buraya bir alan bırakın",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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",
|
||||
"feedback_record_created_successfully": "Geri bildirim kaydı başarıyla oluşturuldu",
|
||||
"feedback_record_deleted_successfully": "Geri bildirim kaydı başarıyla silindi",
|
||||
"feedback_record_details": "Geri bildirim kaydı ayrıntıları",
|
||||
"feedback_record_details_description": "Geri bildirim kayıt alanlarını inceleyin ve güncelleyin.",
|
||||
"feedback_record_fields": "Geri Bildirim Kayıt Alanları",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "Geri bildirim kaydı başarıyla güncellendi",
|
||||
"feedback_record_value_required": "Seçilen alan türü için bir değer gerekli",
|
||||
"feedback_records": "Geri Bildirim Kayıtları",
|
||||
"feedback_records_deleted_successfully": "{count} geri bildirim kaydı silindi",
|
||||
"feedback_records_refreshed": "Geri bildirim kayıtları yenilendi",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "Bu işlevin kullanılabilmesi için önce bu çalışma alanı için bir geri bildirim dizini kurulması gerekiyor. Bir organizasyon sahibinden veya yöneticisinden bir tane atamasını iste.",
|
||||
"no_feedback_directory_linked_title": "Bağlı geri bildirim dizini yok",
|
||||
"no_feedback_records": "Henüz geri bildirim kaydı yok. Bağlayıcıların veri göndermeye başlamasıyla kayıtlar burada görünecek.",
|
||||
"no_formbricks_surveys_available_description": "Bu çalışma alanında henüz anket yok. Geri bildirim kaynağı olarak kullanmak için <surveyLink>Yeni bir anket oluştur</surveyLink>.",
|
||||
"no_source_fields_loaded": "Henüz kaynak alan yüklenmedi",
|
||||
"no_sources_connected": "Henüz bağlı kaynak yok. Başlamak için bir kaynak ekle.",
|
||||
"optional": "İsteğe bağlı",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "添加 {count} 个图表",
|
||||
"chart_removed": "图表已从仪表板中移除",
|
||||
"charts_add_failed": "添加图表到仪表板失败",
|
||||
"charts_add_partial_failure": "添加 {count} 个图表失败",
|
||||
"charts_added_to_dashboard": "图表已添加到仪表板",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "输入自定义来源类型",
|
||||
"default_connector_name_csv": "CSV 导入",
|
||||
"default_connector_name_formbricks": "Formbricks 调查连接",
|
||||
"delete_feedback_record": "删除反馈记录",
|
||||
"delete_feedback_record_confirmation": "这将永久删除该反馈记录并从关联目录中移除。",
|
||||
"delete_feedback_records_confirmation": "这将永久删除 {count} 条反馈记录并从关联目录中移除。",
|
||||
"discard_feedback_record_changes_description": "如果关闭此抽屉,您的更改将会丢失。",
|
||||
"discard_feedback_record_changes_title": "放弃未保存的更改?",
|
||||
"drop_a_field_here": "将字段拖到这里",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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": "反馈目录",
|
||||
"feedback_record_created_successfully": "反馈记录创建成功",
|
||||
"feedback_record_deleted_successfully": "反馈记录已成功删除",
|
||||
"feedback_record_details": "反馈记录详情",
|
||||
"feedback_record_details_description": "查看并更新反馈记录字段。",
|
||||
"feedback_record_fields": "反馈记录字段",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "反馈记录更新成功",
|
||||
"feedback_record_value_required": "所选字段类型需要一个值",
|
||||
"feedback_records": "反馈记录",
|
||||
"feedback_records_deleted_successfully": "已删除 {count} 条反馈记录",
|
||||
"feedback_records_refreshed": "反馈记录已刷新",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "在该功能可用前,需要先为此工作区设置反馈目录。请联系组织所有者或管理员进行分配。",
|
||||
"no_feedback_directory_linked_title": "未关联反馈目录",
|
||||
"no_feedback_records": "暂无反馈记录。当你的连接器开始发送数据后,记录会显示在这里。",
|
||||
"no_formbricks_surveys_available_description": "此工作区还没有调查。<surveyLink>创建新调查</surveyLink>,以将其用作反馈来源。",
|
||||
"no_source_fields_loaded": "尚未加载源字段",
|
||||
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
|
||||
"optional": "可选",
|
||||
|
||||
@@ -1810,6 +1810,7 @@
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "新增 {count} 個圖表",
|
||||
"chart_removed": "圖表已從儀表板移除",
|
||||
"charts_add_failed": "無法將圖表新增至儀表板",
|
||||
"charts_add_partial_failure": "無法新增 {count} 個圖表",
|
||||
"charts_added_to_dashboard": "圖表已新增至儀表板",
|
||||
@@ -3680,6 +3681,9 @@
|
||||
"custom_source_type_placeholder": "輸入自訂來源類型",
|
||||
"default_connector_name_csv": "CSV 匯入",
|
||||
"default_connector_name_formbricks": "Formbricks 問卷連線",
|
||||
"delete_feedback_record": "刪除意見回饋記錄",
|
||||
"delete_feedback_record_confirmation": "這將永久刪除此意見回饋記錄,並從已連結的目錄中移除。",
|
||||
"delete_feedback_records_confirmation": "這將永久刪除 {count} 筆意見回饋記錄,並從已連結的目錄中移除。",
|
||||
"discard_feedback_record_changes_description": "如果關閉此抽屜,您的變更將會遺失。",
|
||||
"discard_feedback_record_changes_title": "放棄未儲存的變更?",
|
||||
"drop_a_field_here": "請將欄位拖曳到這裡",
|
||||
@@ -3689,10 +3693,17 @@
|
||||
"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": "意見回饋目錄",
|
||||
"feedback_record_created_successfully": "回饋記錄創建成功",
|
||||
"feedback_record_deleted_successfully": "意見回饋記錄已成功刪除",
|
||||
"feedback_record_details": "反饋記錄詳情",
|
||||
"feedback_record_details_description": "查看並更新回饋記錄欄位。",
|
||||
"feedback_record_fields": "回饋紀錄欄位",
|
||||
@@ -3700,6 +3711,7 @@
|
||||
"feedback_record_updated_successfully": "回饋記錄更新成功",
|
||||
"feedback_record_value_required": "所選欄位類型需要一個值",
|
||||
"feedback_records": "回饋紀錄",
|
||||
"feedback_records_deleted_successfully": "已刪除 {count} 筆意見回饋記錄",
|
||||
"feedback_records_refreshed": "回饋紀錄已更新",
|
||||
"feedback_sources": "Feedback Sources",
|
||||
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
|
||||
@@ -3736,6 +3748,7 @@
|
||||
"no_feedback_directory_linked_member_description": "此工作區需要先設定意見回饋目錄,才能使用此功能。請請組織擁有者或管理員指定一個目錄。",
|
||||
"no_feedback_directory_linked_title": "未連結意見回饋目錄",
|
||||
"no_feedback_records": "目前尚無回饋紀錄。當你的連接器開始傳送資料時,紀錄會顯示在這裡。",
|
||||
"no_formbricks_surveys_available_description": "此工作區尚無問卷。<surveyLink>建立新問卷</surveyLink>,以將其用作回饋來源。",
|
||||
"no_source_fields_loaded": "尚未載入來源欄位",
|
||||
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
|
||||
"optional": "選填",
|
||||
|
||||
@@ -36,6 +36,16 @@ describe("cube queryRewrite", () => {
|
||||
expect(() => queryRewrite({ measures: ["FeedbackRecords.count"] }, {})).toThrow(
|
||||
/missing tenantId security context/
|
||||
);
|
||||
|
||||
const logPayload = vi.mocked(console.log).mock.calls[0][0];
|
||||
const parsed = JSON.parse(logPayload);
|
||||
expect(parsed).toMatchObject({
|
||||
type: "audit",
|
||||
event: "cube.query",
|
||||
status: "failure",
|
||||
errorName: "Error",
|
||||
errorMessage: "Cube query rejected: missing tenantId security context",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects Cube startup without an API secret", () => {
|
||||
@@ -97,6 +107,18 @@ 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(
|
||||
@@ -197,6 +219,19 @@ describe("cube queryRewrite", () => {
|
||||
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(
|
||||
{
|
||||
@@ -221,6 +256,7 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
duplicateDashboard,
|
||||
getDashboard,
|
||||
getDashboards,
|
||||
removeWidgetFromDashboard,
|
||||
updateDashboard,
|
||||
updateWidgetLayouts,
|
||||
} from "./lib/dashboards";
|
||||
@@ -111,15 +112,13 @@ export const updateDashboardAction = authenticatedActionClient.inputSchema(ZUpda
|
||||
const ZUpdateWidgetLayoutsAction = z.object({
|
||||
workspaceId: ZId,
|
||||
dashboardId: ZId,
|
||||
widgets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: ZId,
|
||||
layout: ZWidgetLayout,
|
||||
order: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
widgets: z.array(
|
||||
z.object({
|
||||
id: ZId,
|
||||
layout: ZWidgetLayout,
|
||||
order: z.number().int().nonnegative(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const updateWidgetLayoutsAction = authenticatedActionClient
|
||||
@@ -325,3 +324,34 @@ export const addChartToDashboardAction = authenticatedActionClient
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZRemoveWidgetFromDashboardAction = z.object({
|
||||
workspaceId: ZId,
|
||||
dashboardId: ZId,
|
||||
widgetId: ZId,
|
||||
});
|
||||
|
||||
export const removeWidgetFromDashboardAction = authenticatedActionClient
|
||||
.inputSchema(ZRemoveWidgetFromDashboardAction)
|
||||
.action(
|
||||
withAuditLogging("deleted", "dashboardWidget", async ({ ctx, parsedInput }) => {
|
||||
const { organizationId, workspaceId } = await checkWorkspaceAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.workspaceId,
|
||||
"readWrite"
|
||||
);
|
||||
await checkDashboardsEnabled(organizationId);
|
||||
|
||||
const widget = await removeWidgetFromDashboard(
|
||||
parsedInput.dashboardId,
|
||||
workspaceId,
|
||||
parsedInput.widgetId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
|
||||
ctx.auditLoggingCtx.oldObject = widget;
|
||||
return { success: true };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -134,7 +134,7 @@ export function AddExistingChartsDialog({
|
||||
<DialogTitle>{t("common.add_charts")}</DialogTitle>
|
||||
<DialogDescription>{t("common.add_existing_chart_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<DialogBody className="p-1">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center rounded-md border px-3 py-2">
|
||||
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
|
||||
|
||||
@@ -17,10 +17,15 @@ import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/das
|
||||
import { DashboardWidgetData } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-data";
|
||||
import { DashboardWidgetSkeleton } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-skeleton";
|
||||
import type { TChartDataRow, TDashboardDetail, TDashboardWidget } from "@/modules/ee/analysis/types/analysis";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { updateDashboardAction, updateWidgetLayoutsAction } from "../actions";
|
||||
import {
|
||||
removeWidgetFromDashboardAction,
|
||||
updateDashboardAction,
|
||||
updateWidgetLayoutsAction,
|
||||
} from "../actions";
|
||||
import type { TDashboardWidgetError } from "../lib/widget-errors";
|
||||
|
||||
const ROW_HEIGHT = 80;
|
||||
@@ -163,6 +168,8 @@ export function DashboardDetailClient({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editingChartId, setEditingChartId] = useState<string | null>(null);
|
||||
const [widgetIdToRemove, setWidgetIdToRemove] = useState<string | null>(null);
|
||||
const [isRemovingWidget, setIsRemovingWidget] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [name, setName] = useState(dashboard.name);
|
||||
@@ -207,17 +214,36 @@ export function DashboardDetailClient({
|
||||
|
||||
const handleRemoveWidgetFromMenu = useCallback(
|
||||
(widgetId: string) => {
|
||||
if (!isEditing) {
|
||||
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
|
||||
setIsEditing(true);
|
||||
if (isEditing) {
|
||||
handleRemoveWidget(widgetId);
|
||||
return;
|
||||
}
|
||||
|
||||
handleRemoveWidget(widgetId);
|
||||
setWidgetIdToRemove(widgetId);
|
||||
},
|
||||
[dashboard.widgets, handleRemoveWidget, isEditing]
|
||||
[handleRemoveWidget, isEditing]
|
||||
);
|
||||
|
||||
const handleConfirmRemoveWidget = useCallback(async () => {
|
||||
if (!widgetIdToRemove) return;
|
||||
setIsRemovingWidget(true);
|
||||
try {
|
||||
const result = await removeWidgetFromDashboardAction({
|
||||
workspaceId,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId: widgetIdToRemove,
|
||||
});
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("workspace.analysis.dashboards.chart_removed"));
|
||||
setWidgetIdToRemove(null);
|
||||
startTransition(() => router.refresh());
|
||||
} finally {
|
||||
setIsRemovingWidget(false);
|
||||
}
|
||||
}, [widgetIdToRemove, workspaceId, dashboard.id, router, t, startTransition]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setName(dashboard.name);
|
||||
setDraftWidgets(null);
|
||||
@@ -373,6 +399,17 @@ export function DashboardDetailClient({
|
||||
aiUnavailableReason={aiUnavailableReason}
|
||||
/>
|
||||
)}
|
||||
{!isReadOnly && (
|
||||
<DeleteDialog
|
||||
open={widgetIdToRemove !== null}
|
||||
setOpen={(open) => {
|
||||
if (!open) setWidgetIdToRemove(null);
|
||||
}}
|
||||
deleteWhat={t("common.chart")}
|
||||
onDelete={handleConfirmRemoveWidget}
|
||||
isDeleting={isRemovingWidget}
|
||||
/>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@ var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
|
||||
var mockTxWidget: {
|
||||
// NOSONAR / test code
|
||||
aggregate: ReturnType<typeof vi.fn>;
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
@@ -29,9 +31,11 @@ vi.mock("@formbricks/database", () => {
|
||||
const txChart = { findFirst: vi.fn() };
|
||||
const txWidget = {
|
||||
aggregate: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
mockTxDashboard = txDash;
|
||||
@@ -44,6 +48,7 @@ vi.mock("@formbricks/database", () => {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
dashboardWidget: txWidget,
|
||||
$transaction: vi.fn((cb: any) => cb({ dashboard: txDash, chart: txChart, dashboardWidget: txWidget })),
|
||||
},
|
||||
};
|
||||
@@ -672,4 +677,52 @@ describe("Dashboard Service", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeWidgetFromDashboard", () => {
|
||||
const mockWidgetId = "widget-abc-123";
|
||||
|
||||
test("deletes a widget that belongs to the dashboard", async () => {
|
||||
const mockWidget = { id: mockWidgetId, dashboardId: mockDashboardId, chartId: mockChartId };
|
||||
mockTxWidget.findFirst.mockResolvedValue(mockWidget);
|
||||
mockTxWidget.delete.mockResolvedValue(mockWidget);
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId);
|
||||
|
||||
expect(result).toEqual(mockWidget);
|
||||
expect(mockTxWidget.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockWidgetId, dashboard: { id: mockDashboardId, workspaceId: mockWorkspaceId } },
|
||||
});
|
||||
expect(mockTxWidget.delete).toHaveBeenCalledWith({ where: { id: mockWidgetId } });
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when the widget is not on the dashboard", async () => {
|
||||
mockTxWidget.findFirst.mockResolvedValue(null);
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)
|
||||
).rejects.toMatchObject({ name: "ResourceNotFoundError", resourceType: "DashboardWidget" });
|
||||
expect(mockTxWidget.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("wraps Prisma errors in DatabaseError", async () => {
|
||||
mockTxWidget.findFirst.mockRejectedValue(makePrismaError("P9999"));
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)
|
||||
).rejects.toMatchObject({ name: "DatabaseError" });
|
||||
});
|
||||
|
||||
test("rethrows unknown errors", async () => {
|
||||
const error = new Error("boom");
|
||||
mockTxWidget.findFirst.mockRejectedValue(error);
|
||||
const { removeWidgetFromDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)).rejects.toBe(
|
||||
error
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,6 +301,31 @@ export const updateWidgetLayouts = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const removeWidgetFromDashboard = async (
|
||||
dashboardId: string,
|
||||
workspaceId: string,
|
||||
widgetId: string
|
||||
) => {
|
||||
validateInputs([dashboardId, ZId], [workspaceId, ZId], [widgetId, ZId]);
|
||||
|
||||
try {
|
||||
const widget = await prisma.dashboardWidget.findFirst({
|
||||
where: { id: widgetId, dashboard: { id: dashboardId, workspaceId } },
|
||||
});
|
||||
|
||||
if (!widget) {
|
||||
throw new ResourceNotFoundError("DashboardWidget", widgetId);
|
||||
}
|
||||
|
||||
return await prisma.dashboardWidget.delete({ where: { id: widgetId } });
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const addChartToDashboard = async (data: TAddWidgetInput) => {
|
||||
validateInputs([data, ZAddWidgetInput]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -289,6 +289,47 @@ describe("FeedbackDirectory Service", () => {
|
||||
).rejects.toThrow(new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG"));
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when a workspace is already assigned to another active directory", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId1,
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
createFeedbackDirectory(mockOrganizationId, "Conflicting", [mockWorkspaceId1])
|
||||
).rejects.toThrow(new InvalidInputError("WORKSPACE_ALREADY_ASSIGNED_TO_DIFFERENT_DIRECTORY"));
|
||||
|
||||
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: { in: [mockWorkspaceId1] },
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
expect(prisma.feedbackDirectory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows creation when workspace is only assigned to archived directory", async () => {
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.feedbackDirectory.create).mockResolvedValueOnce({
|
||||
id: mockDirectoryId,
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackDirectory(mockOrganizationId, "ArchivedOnly", [mockWorkspaceId1]);
|
||||
|
||||
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: { in: [mockWorkspaceId1] },
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
|
||||
expect(prisma.feedbackDirectory.create).toHaveBeenCalled();
|
||||
expect(result).toBe(mockDirectoryId);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
|
||||
@@ -279,6 +279,7 @@ export const createFeedbackDirectory = async (
|
||||
if (count !== workspaceIds.length) {
|
||||
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
|
||||
}
|
||||
await assertWorkspacesNotAssignedElsewhere(undefined, workspaceIds);
|
||||
}
|
||||
|
||||
const directory = await prisma.feedbackDirectory.create({
|
||||
@@ -440,9 +441,12 @@ const pauseConnectorsInWorkspaces = async (
|
||||
* Enforces the single-active-FRD-per-workspace invariant. The client UI prevents
|
||||
* assigning a workspace to multiple active directories, but the server must also
|
||||
* reject such payloads to keep this guarantee under direct API access.
|
||||
*
|
||||
* Pass `directoryId` when updating an existing directory to exclude it from the
|
||||
* conflict check. Omit it on create — every active directory is a conflict.
|
||||
*/
|
||||
const assertWorkspacesNotAssignedElsewhere = async (
|
||||
directoryId: string,
|
||||
directoryId: string | undefined,
|
||||
workspaceIds: string[]
|
||||
): Promise<void> => {
|
||||
if (workspaceIds.length === 0) return;
|
||||
@@ -450,7 +454,7 @@ const assertWorkspacesNotAssignedElsewhere = async (
|
||||
const conflicting = await prisma.feedbackDirectoryWorkspace.findFirst({
|
||||
where: {
|
||||
workspaceId: { in: workspaceIds },
|
||||
feedbackDirectoryId: { not: directoryId },
|
||||
...(directoryId === undefined ? {} : { feedbackDirectoryId: { not: directoryId } }),
|
||||
feedbackDirectory: { isArchived: false },
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { FeedbackDirectoryView } from "@/modules/ee/feedback-directory/components/feedback-directory-view";
|
||||
import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
@@ -16,9 +17,12 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
|
||||
const { isOwner, isManager } = getAccessFlags(currentUserMembership.role);
|
||||
|
||||
const isFeedbackDirectoriesAllowed = await getIsFeedbackDirectoriesEnabled(organization.id);
|
||||
const pageTitle = t("workspace.settings.feedback_directories.title");
|
||||
|
||||
if (!isFeedbackDirectoriesAllowed) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} />
|
||||
<div className="flex items-center justify-center">
|
||||
<UpgradePrompt
|
||||
title={t("workspace.settings.feedback_directories.upgrade_prompt_title")}
|
||||
@@ -47,6 +51,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
|
||||
if (!isOwner && !isManager) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} />
|
||||
<p className="text-sm text-slate-500">{t("workspace.settings.feedback_directories.no_access")}</p>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
@@ -54,6 +59,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} />
|
||||
<FeedbackDirectoryView organizationId={organization.id} membershipRole={currentUserMembership.role} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
"use server";
|
||||
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory";
|
||||
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service";
|
||||
import {
|
||||
createFeedbackRecord,
|
||||
deleteFeedbackRecord,
|
||||
retrieveFeedbackRecord,
|
||||
updateFeedbackRecord,
|
||||
} from "@/modules/hub/service";
|
||||
import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types";
|
||||
import {
|
||||
TCreateFeedbackRecordAction,
|
||||
TRetrieveFeedbackRecordAction,
|
||||
TUpdateFeedbackRecordAction,
|
||||
ZCreateFeedbackRecordAction,
|
||||
ZDeleteFeedbackRecordAction,
|
||||
ZRetrieveFeedbackRecordAction,
|
||||
ZUpdateFeedbackRecordAction,
|
||||
} from "./types";
|
||||
@@ -176,3 +182,26 @@ export const updateFeedbackRecordAction = authenticatedActionClient
|
||||
return updateResult.data;
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteFeedbackRecordAction = authenticatedActionClient
|
||||
.inputSchema(ZDeleteFeedbackRecordAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const [, workspaceDirectoryIds] = await Promise.all([
|
||||
ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"),
|
||||
getWorkspaceDirectoryIds(parsedInput.workspaceId),
|
||||
]);
|
||||
|
||||
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!currentRecordResult.data || currentRecordResult.error) {
|
||||
throw new ResourceNotFoundError("Feedback record", parsedInput.recordId);
|
||||
}
|
||||
|
||||
assertRecordBelongsToWorkspace(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
|
||||
|
||||
const deleteResult = await deleteFeedbackRecord(parsedInput.recordId);
|
||||
if (!deleteResult.ok || deleteResult.error) {
|
||||
throw new Error(deleteResult.error?.message || "Failed to delete feedback record");
|
||||
}
|
||||
|
||||
return { recordId: parsedInput.recordId };
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import {
|
||||
createFeedbackRecordAction,
|
||||
deleteFeedbackRecordAction,
|
||||
retrieveFeedbackRecordAction,
|
||||
updateFeedbackRecordAction,
|
||||
} from "../actions";
|
||||
@@ -87,6 +89,8 @@ export const FeedbackRecordFormDrawer = ({
|
||||
const [isLoadingRecord, setIsLoadingRecord] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
|
||||
|
||||
@@ -283,6 +287,24 @@ export const FeedbackRecordFormDrawer = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!recordId) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteFeedbackRecordAction({ workspaceId, recordId });
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("workspace.unify.feedback_record_deleted_successfully"));
|
||||
setIsDeleteDialogOpen(false);
|
||||
await onSuccess();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
form.clearErrors();
|
||||
|
||||
@@ -785,15 +807,27 @@ export const FeedbackRecordFormDrawer = ({
|
||||
</FormProvider>
|
||||
)}
|
||||
|
||||
<SheetFooter className="mt-2">
|
||||
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
|
||||
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
|
||||
<SheetFooter className="mt-2 sm:justify-between">
|
||||
{isEditMode && canWrite && recordId ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
disabled={isSubmitting || isLoadingRecord || isDeleting}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
|
||||
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -809,6 +843,15 @@ export const FeedbackRecordFormDrawer = ({
|
||||
onDecline={() => setIsDiscardDialogOpen(false)}
|
||||
onConfirm={handleDiscardChanges}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
deleteWhat={t("workspace.unify.delete_feedback_record")}
|
||||
text={t("workspace.unify.delete_feedback_record_confirmation")}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ToggleLeftIcon,
|
||||
Trash2Icon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -21,6 +22,8 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { deleteFeedbackRecordAction } from "../actions";
|
||||
import { formatSourceType } from "../lib/utils";
|
||||
import { CsvImportModal } from "../sources/components/csv-import-modal";
|
||||
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
|
||||
@@ -87,8 +91,40 @@ export const FeedbackRecordsTable = ({
|
||||
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const hasMore = Object.keys(cursors).length > 0;
|
||||
const selectedCount = selectedIds.size;
|
||||
const allOnPageSelected = records.length > 0 && records.every((record) => selectedIds.has(record.id));
|
||||
const someOnPageSelected = records.some((record) => selectedIds.has(record.id));
|
||||
|
||||
const toggleAllOnPage = (checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
records.forEach((record) => next.add(record.id));
|
||||
} else {
|
||||
records.forEach((record) => next.delete(record.id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleOne = (recordId: string, checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
next.add(recordId);
|
||||
} else {
|
||||
next.delete(recordId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelectedIds(new Set());
|
||||
|
||||
const directories = useMemo(
|
||||
() =>
|
||||
@@ -160,6 +196,7 @@ export const FeedbackRecordsTable = ({
|
||||
const mergedRecords = result.records.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1));
|
||||
setRecords(mergedRecords);
|
||||
setCursors(result.newCursors);
|
||||
setSelectedIds(new Set());
|
||||
setIsRefreshing(false);
|
||||
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
|
||||
};
|
||||
@@ -199,6 +236,51 @@ export const FeedbackRecordsTable = ({
|
||||
|
||||
const isEmpty = records.length === 0 && !isRefreshing;
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
setIsDeleting(true);
|
||||
const CHUNK_SIZE = 5;
|
||||
const failedIds: string[] = [];
|
||||
try {
|
||||
for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
|
||||
const chunk = ids.slice(i, i + CHUNK_SIZE);
|
||||
const results = await Promise.all(
|
||||
chunk.map(async (recordId) => ({
|
||||
recordId,
|
||||
result: await deleteFeedbackRecordAction({ workspaceId, recordId }),
|
||||
}))
|
||||
);
|
||||
results.forEach(({ recordId, result }) => {
|
||||
if (!result?.data) failedIds.push(recordId);
|
||||
});
|
||||
}
|
||||
|
||||
const succeeded = ids.filter((id) => !failedIds.includes(id));
|
||||
if (succeeded.length > 0) {
|
||||
setRecords((prev) => prev.filter((record) => !succeeded.includes(record.id)));
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
succeeded.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (failedIds.length === 0) {
|
||||
toast.success(
|
||||
t("workspace.unify.feedback_records_deleted_successfully", { count: succeeded.length })
|
||||
);
|
||||
} else if (succeeded.length === 0) {
|
||||
toast.error(t("workspace.unify.failed_to_load_feedback_records"));
|
||||
} else {
|
||||
toast.error(`${succeeded.length}/${ids.length} deleted`);
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDrawer = (recordId: string) => {
|
||||
setDrawerMode("edit");
|
||||
setDrawerRecordId(recordId);
|
||||
@@ -217,7 +299,26 @@ export const FeedbackRecordsTable = ({
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{isEmpty ? (
|
||||
{selectedCount > 0 ? (
|
||||
<div className="flex items-center gap-x-2 rounded-md bg-primary p-1 px-2 text-xs text-white">
|
||||
<span className="lowercase">
|
||||
{`${selectedCount} ${t("workspace.unify.feedback_records").toLowerCase()} ${t("common.selected")}`}
|
||||
</span>
|
||||
<span>|</span>
|
||||
<Button variant="outline" size="sm" className="h-6 border-none px-2" onClick={clearSelection}>
|
||||
{t("common.clear_selection")}
|
||||
</Button>
|
||||
<span>|</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}>
|
||||
{t("common.delete")}
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<span />
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
@@ -280,6 +381,13 @@ export const FeedbackRecordsTable = ({
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="w-10 px-4 py-3">
|
||||
<Checkbox
|
||||
aria-label={t("common.select_all")}
|
||||
checked={allOnPageSelected ? true : someOnPageSelected ? "indeterminate" : false}
|
||||
onCheckedChange={(checked) => toggleAllOnPage(checked === true)}
|
||||
/>
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
@@ -292,7 +400,7 @@ export const FeedbackRecordsTable = ({
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<td colSpan={8}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
@@ -308,6 +416,8 @@ export const FeedbackRecordsTable = ({
|
||||
workspaceId={workspaceId}
|
||||
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
|
||||
t={t}
|
||||
isSelected={selectedIds.has(record.id)}
|
||||
onSelectChange={(checked) => toggleOne(record.id, checked)}
|
||||
onClick={() => openEditDrawer(record.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -342,6 +452,15 @@ export const FeedbackRecordsTable = ({
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
open={isBulkDeleteDialogOpen}
|
||||
setOpen={setIsBulkDeleteDialogOpen}
|
||||
deleteWhat={t("workspace.unify.feedback_records")}
|
||||
text={t("workspace.unify.delete_feedback_records_confirmation", { count: selectedCount })}
|
||||
onDelete={handleBulkDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
{csvImportSource && (
|
||||
<CsvImportModal
|
||||
open={csvImportSource !== null}
|
||||
@@ -363,12 +482,16 @@ const FeedbackRecordRow = ({
|
||||
workspaceId,
|
||||
locale,
|
||||
t,
|
||||
isSelected,
|
||||
onSelectChange,
|
||||
onClick,
|
||||
}: {
|
||||
record: FeedbackRecordData;
|
||||
workspaceId: string;
|
||||
locale: string;
|
||||
t: TFunction;
|
||||
isSelected: boolean;
|
||||
onSelectChange: (checked: boolean) => void;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const value = formatValue(record, t, locale);
|
||||
@@ -379,10 +502,11 @@ const FeedbackRecordRow = ({
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="cursor-pointer text-sm text-slate-700 transition-colors focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
||||
className={`cursor-pointer text-sm text-slate-700 transition-colors focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 ${isSelected ? "bg-slate-50" : ""}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={record.field_label ?? record.field_id}
|
||||
aria-selected={isSelected}
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
@@ -390,6 +514,16 @@ const FeedbackRecordRow = ({
|
||||
onClick();
|
||||
}
|
||||
}}>
|
||||
<td
|
||||
className="w-10 px-4 py-3"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}>
|
||||
<Checkbox
|
||||
aria-label={record.field_label ?? record.field_id}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => onSelectChange(checked === true)}
|
||||
/>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
|
||||
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
|
||||
</td>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { TConnectorOptionId, getConnectorOptions } from "../utils";
|
||||
|
||||
interface ConnectorTypeSelectorProps {
|
||||
selectedType: TConnectorOptionId | null;
|
||||
onSelectType: (type: TConnectorOptionId) => void;
|
||||
workspaceId: string;
|
||||
surveyCount: number;
|
||||
}
|
||||
|
||||
const getOptionClassName = (
|
||||
@@ -27,43 +29,54 @@ const getOptionClassName = (
|
||||
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
|
||||
};
|
||||
|
||||
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
|
||||
export function ConnectorTypeSelector({
|
||||
selectedType,
|
||||
onSelectType,
|
||||
workspaceId,
|
||||
surveyCount,
|
||||
}: Readonly<ConnectorTypeSelectorProps>) {
|
||||
const { t } = useTranslation();
|
||||
const connectorOptions = getConnectorOptions(t);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{connectorOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => onSelectType(option.id)}
|
||||
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
|
||||
selectedType,
|
||||
option.id,
|
||||
option.disabled
|
||||
)}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
|
||||
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-3 h-4 w-4 rounded-full border-2 ${
|
||||
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
|
||||
}`}>
|
||||
{selectedType === option.id && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white" />
|
||||
{connectorOptions.map((option) => {
|
||||
const showNoSurveysAlert =
|
||||
surveyCount === 0 && option.id === "formbricks_survey" && selectedType === "formbricks_survey";
|
||||
return (
|
||||
<div key={option.id} className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => onSelectType(option.id)}
|
||||
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
|
||||
selectedType,
|
||||
option.id,
|
||||
option.disabled
|
||||
)}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
|
||||
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`ml-3 h-4 w-4 rounded-full border-2 ${
|
||||
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
|
||||
}`}>
|
||||
{selectedType === option.id && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{showNoSurveysAlert && <NoFormbricksSurveysAlert workspaceId={workspaceId} />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Alert variant="outbound" size="small">
|
||||
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
|
||||
@@ -80,3 +93,20 @@ export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<C
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NoFormbricksSurveysAlert = ({ workspaceId }: Readonly<{ workspaceId: string }>) => {
|
||||
return (
|
||||
<Alert variant="info" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
<Trans
|
||||
i18nKey="workspace.unify.no_formbricks_surveys_available_description"
|
||||
components={{
|
||||
surveyLink: (
|
||||
<Link href={`/workspaces/${workspaceId}/surveys/templates`} className="font-medium underline" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { TFieldMapping, TUnifySurvey } from "../types";
|
||||
import { TFieldMapping, TUnifySurvey, getTranslatedConnectorError } from "../types";
|
||||
import { ConnectorsTable } from "./connectors-table";
|
||||
import { CreateConnectorModal } from "./create-connector-modal";
|
||||
import { CsvImportModal } from "./csv-import-modal";
|
||||
@@ -28,6 +28,7 @@ interface ConnectorsSectionProps {
|
||||
initialConnectors: TConnectorWithMappings[];
|
||||
initialSurveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export function ConnectorsSection({
|
||||
@@ -35,6 +36,7 @@ export function ConnectorsSection({
|
||||
initialConnectors,
|
||||
initialSurveys,
|
||||
directories,
|
||||
isReadOnly,
|
||||
}: Readonly<ConnectorsSectionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -78,7 +80,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -93,7 +95,7 @@ export function ConnectorsSection({
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => {
|
||||
}): Promise<boolean> => {
|
||||
const result = await updateConnectorWithMappingsAction({
|
||||
connectorId: data.connectorId,
|
||||
workspaceId: workspaceId,
|
||||
@@ -111,19 +113,20 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_updated_successfully"));
|
||||
router.refresh();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
|
||||
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -138,7 +141,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,7 +158,7 @@ export function ConnectorsSection({
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,11 +173,15 @@ export function ConnectorsSection({
|
||||
<SettingsCard
|
||||
title={t("workspace.unify.feedback_sources")}
|
||||
description={t("workspace.unify.feedback_sources_settings_description")}
|
||||
buttonInfo={{
|
||||
text: t("workspace.unify.add_source"),
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
variant: "default",
|
||||
}}>
|
||||
buttonInfo={
|
||||
isReadOnly
|
||||
? undefined
|
||||
: {
|
||||
text: t("workspace.unify.add_source"),
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
variant: "default",
|
||||
}
|
||||
}>
|
||||
<ConnectorsTable
|
||||
connectors={initialConnectors}
|
||||
onConnectorClick={setEditingConnector}
|
||||
@@ -183,15 +190,18 @@ export function ConnectorsSection({
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={handleDeleteConnector}
|
||||
isLoading={false}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
{directories.length > 0 && (
|
||||
<Alert size="small" className="mt-4">
|
||||
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
{!isReadOnly && (
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsCard>
|
||||
@@ -208,6 +218,7 @@ export function ConnectorsSection({
|
||||
|
||||
<EditConnectorModal
|
||||
connector={editingConnector}
|
||||
isReadOnly={isReadOnly}
|
||||
open={editingConnector !== null}
|
||||
onOpenChange={(open) => !open && setEditingConnector(null)}
|
||||
onUpdateConnector={handleUpdateConnector}
|
||||
|
||||
+15
-11
@@ -37,6 +37,7 @@ interface ConnectorsTableDataRowProps {
|
||||
onDuplicate: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
|
||||
@@ -52,10 +53,11 @@ export function ConnectorsTableDataRow({
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isReadOnly = false,
|
||||
}: Readonly<ConnectorsTableDataRowProps>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const handleRowClick = () => {
|
||||
if (connector.type === "csv" && onCsvImport) {
|
||||
if (!isReadOnly && connector.type === "csv" && onCsvImport) {
|
||||
onCsvImport();
|
||||
return;
|
||||
}
|
||||
@@ -93,10 +95,10 @@ export function ConnectorsTableDataRow({
|
||||
title={t(getConnectorTypeLabelKey(connector.type))}>
|
||||
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
|
||||
</div>
|
||||
<div className="col-span-5 flex items-center">
|
||||
<div className="col-span-4 flex items-center">
|
||||
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center justify-center sm:flex">
|
||||
<div className="col-span-2 hidden items-center justify-center sm:flex">
|
||||
<Badge
|
||||
text={getStatusLabel(connector.status, connector.type)}
|
||||
type={STATUS_BADGE_TYPE[connector.status]}
|
||||
@@ -110,14 +112,16 @@ export function ConnectorsTableDataRow({
|
||||
<span className="truncate">{connector.creatorName ?? "—"}</span>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-end pr-2">
|
||||
<ConnectorRowDropdown
|
||||
connector={connector}
|
||||
onEdit={onEdit}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
{!isReadOnly && (
|
||||
<ConnectorRowDropdown
|
||||
connector={connector}
|
||||
onEdit={onEdit}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
@@ -9,6 +9,7 @@ interface ConnectorsTableRowsContainerProps {
|
||||
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ConnectorsTableRowsContainer = ({
|
||||
@@ -18,6 +19,7 @@ export const ConnectorsTableRowsContainer = ({
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isReadOnly = false,
|
||||
}: ConnectorsTableRowsContainerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -40,6 +42,7 @@ export const ConnectorsTableRowsContainer = ({
|
||||
onDuplicate={() => onDuplicate(connector)}
|
||||
onToggleStatus={() => onToggleStatus(connector)}
|
||||
onDelete={() => onDelete(connector.id)}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ConnectorsTableProps {
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectorsTable({
|
||||
@@ -23,6 +24,7 @@ export function ConnectorsTable({
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
isReadOnly = false,
|
||||
}: Readonly<ConnectorsTableProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -30,8 +32,8 @@ export function ConnectorsTable({
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">{t("common.type")}</div>
|
||||
<div className="col-span-5">{t("common.name")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
|
||||
<div className="col-span-4">{t("common.name")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.status")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
|
||||
<div className="col-span-1" />
|
||||
@@ -48,6 +50,7 @@ export function ConnectorsTable({
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -416,16 +427,23 @@ export const CreateConnectorModal = ({
|
||||
|
||||
<div className="py-4">
|
||||
{currentStep === "selectType" && (
|
||||
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||
<ConnectorTypeSelector
|
||||
selectedType={selectedType}
|
||||
onSelectType={setSelectedType}
|
||||
surveyCount={surveys.length}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={formbricksForm.handleSubmit(handleCreateFormbricksConnector)}>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -435,7 +453,9 @@ export const CreateConnectorModal = ({
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -445,7 +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>
|
||||
@@ -462,7 +482,9 @@ export const CreateConnectorModal = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -470,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>
|
||||
@@ -482,7 +504,9 @@ export const CreateConnectorModal = ({
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -578,7 +602,9 @@ export const CreateConnectorModal = ({
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === "selectType" ? (
|
||||
<Button onClick={handleNextStep} disabled={!selectedType}>
|
||||
<Button
|
||||
onClick={handleNextStep}
|
||||
disabled={!selectedType || (selectedType === "formbricks_survey" && surveys.length === 0)}>
|
||||
{getNextStepButtonLabel(selectedType, t)}
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
ZFormbricksConnectorForm,
|
||||
getTranslatedConnectorError,
|
||||
} from "../types";
|
||||
import {
|
||||
areAllRequiredFieldsMapped,
|
||||
@@ -59,9 +60,10 @@ interface EditConnectorModalProps {
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<void>;
|
||||
}) => Promise<boolean>;
|
||||
surveys: TUnifySurvey[];
|
||||
onOpenCsvImport?: () => void;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export const EditConnectorModal = ({
|
||||
@@ -71,6 +73,7 @@ export const EditConnectorModal = ({
|
||||
onUpdateConnector,
|
||||
surveys,
|
||||
onOpenCsvImport,
|
||||
isReadOnly = false,
|
||||
}: EditConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
@@ -174,7 +177,7 @@ export const EditConnectorModal = ({
|
||||
const handleUpdateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
|
||||
if (connector?.type !== "formbricks_survey") return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
const success = await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: values.sourceName.trim(),
|
||||
@@ -182,13 +185,15 @@ export const EditConnectorModal = ({
|
||||
fieldMappings: undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
if (success) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCsvConnector = async () => {
|
||||
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
const success = await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: csvConnectorName.trim(),
|
||||
@@ -196,7 +201,9 @@ export const EditConnectorModal = ({
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
if (success) {
|
||||
handleOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormbricksQuestionToggle = (questionId: string) => {
|
||||
@@ -239,11 +246,13 @@ export const EditConnectorModal = ({
|
||||
<div className="space-y-4 py-4">
|
||||
{connector.type === "formbricks_survey" ? (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={formbricksForm.handleSubmit(handleUpdateFormbricksConnector)}>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -251,9 +260,12 @@ export const EditConnectorModal = ({
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -261,7 +273,7 @@ export const EditConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
@@ -281,7 +293,9 @@ export const EditConnectorModal = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -289,19 +303,21 @@ export const EditConnectorModal = ({
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<fieldset className={isReadOnly ? "opacity-70" : undefined} disabled={isReadOnly}>
|
||||
<FormbricksQuestionList
|
||||
survey={selectedSurvey}
|
||||
selectedQuestionIds={selectedQuestionIds}
|
||||
onQuestionToggle={handleFormbricksQuestionToggle}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
{error?.message && (
|
||||
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -328,41 +344,54 @@ export const EditConnectorModal = ({
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<fieldset
|
||||
disabled={isReadOnly}
|
||||
className={`max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4 ${
|
||||
isReadOnly ? "opacity-70" : ""
|
||||
}`}>
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
connectorType={connector.type}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{connector.type === "csv" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenCsvImport?.();
|
||||
}}>
|
||||
{t("workspace.unify.import_feedback")}
|
||||
{isReadOnly ? (
|
||||
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{connector.type === "csv" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenCsvImport?.();
|
||||
}}>
|
||||
{t("workspace.unify.import_feedback")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -17,8 +17,16 @@ export const WorkspaceFeedbackSourcesPage = async (
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session, organization } =
|
||||
await getWorkspaceAuth(params.workspaceId);
|
||||
const {
|
||||
isOwner,
|
||||
isManager,
|
||||
hasReadAccess,
|
||||
hasReadWriteAccess,
|
||||
hasManageAccess,
|
||||
isReadOnly,
|
||||
session,
|
||||
organization,
|
||||
} = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
@@ -72,6 +80,7 @@ export const WorkspaceFeedbackSourcesPage = async (
|
||||
initialConnectors={connectors}
|
||||
initialSurveys={unifySurveys}
|
||||
directories={directories}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -220,10 +220,29 @@ export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSc
|
||||
export type TCreateConnectorStep = "selectType" | "mapping";
|
||||
|
||||
export const ZFormbricksConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
sourceName: z.string().trim().min(1, "CONNECTOR_NAME_REQUIRED"),
|
||||
surveyId: z.string().min(1, "CONNECTOR_SURVEY_REQUIRED"),
|
||||
selectedQuestionIds: z.array(z.string()).min(1, "CONNECTOR_QUESTIONS_REQUIRED"),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
|
||||
export type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
|
||||
|
||||
export const getTranslatedConnectorError = (errorCode: string, t: TFunction): string => {
|
||||
switch (errorCode) {
|
||||
case "CONNECTOR_NAME_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_name_duplicate");
|
||||
case "CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_formbricks_mapping_duplicate");
|
||||
case "CONNECTOR_FIELD_MAPPING_DUPLICATE":
|
||||
return t("workspace.unify.error_connector_field_mapping_duplicate");
|
||||
case "CONNECTOR_NAME_REQUIRED":
|
||||
return t("workspace.unify.error_connector_name_required");
|
||||
case "CONNECTOR_SURVEY_REQUIRED":
|
||||
return t("workspace.unify.error_connector_survey_required");
|
||||
case "CONNECTOR_QUESTIONS_REQUIRED":
|
||||
return t("workspace.unify.error_connector_questions_required");
|
||||
default:
|
||||
return errorCode;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,3 +78,10 @@ export const ZUpdateFeedbackRecordAction = z.object({
|
||||
});
|
||||
|
||||
export type TUpdateFeedbackRecordAction = z.infer<typeof ZUpdateFeedbackRecordAction>;
|
||||
|
||||
export const ZDeleteFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordId: ZFeedbackRecordId,
|
||||
});
|
||||
|
||||
export type TDeleteFeedbackRecordAction = z.infer<typeof ZDeleteFeedbackRecordAction>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import FormbricksHub from "@formbricks/hub";
|
||||
import {
|
||||
createFeedbackRecord,
|
||||
createFeedbackRecordsBatch,
|
||||
deleteFeedbackRecord,
|
||||
getFeedbackRecordTenant,
|
||||
listFeedbackRecords,
|
||||
retrieveFeedbackRecord,
|
||||
@@ -278,6 +279,53 @@ describe("hub service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteFeedbackRecord", () => {
|
||||
test("returns config error when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
const result = await deleteFeedbackRecord("rec-1");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error?.message).toContain("HUB_API_KEY");
|
||||
});
|
||||
|
||||
test("returns ok when client.delete resolves", async () => {
|
||||
const deleteSpy = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { delete: deleteSpy },
|
||||
} as any);
|
||||
|
||||
const result = await deleteFeedbackRecord("rec-1");
|
||||
|
||||
expect(deleteSpy).toHaveBeenCalledWith("rec-1");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
test("returns error when client.delete throws APIError", async () => {
|
||||
const apiError = new (FormbricksHub as any).APIError("Forbidden", 403);
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { delete: vi.fn().mockRejectedValue(apiError) },
|
||||
} as any);
|
||||
|
||||
const result = await deleteFeedbackRecord("rec-1");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatchObject({ status: 403, message: "Forbidden" });
|
||||
});
|
||||
|
||||
test("returns error when client.delete throws non-API error", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue({
|
||||
feedbackRecords: { delete: vi.fn().mockRejectedValue(new Error("network")) },
|
||||
} as any);
|
||||
|
||||
const result = await deleteFeedbackRecord("rec-1");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toMatchObject({ status: 0, message: "network" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeedbackRecordsBatch", () => {
|
||||
test("returns all errors when getHubClient returns null", async () => {
|
||||
vi.mocked(getHubClient).mockReturnValue(null);
|
||||
|
||||
@@ -98,6 +98,31 @@ export const updateFeedbackRecord = async (
|
||||
}
|
||||
};
|
||||
|
||||
export type HubFeedbackRecordDeleteResult = {
|
||||
ok: boolean;
|
||||
error: HubError | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a single feedback record in the Hub by id.
|
||||
*/
|
||||
export const deleteFeedbackRecord = async (id: string): Promise<HubFeedbackRecordDeleteResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { ok: false, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
|
||||
try {
|
||||
await client.feedbackRecords.delete(id);
|
||||
return { ok: true, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, id }, "Hub: deleteFeedbackRecord failed");
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const message = getErrorMessage(err);
|
||||
return { ok: false, error: { status, message, detail: message } };
|
||||
}
|
||||
};
|
||||
|
||||
export type ListFeedbackRecordsResult = {
|
||||
data: FeedbackRecordListResponse | null;
|
||||
error: HubError | null;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -48,10 +48,15 @@ The intended defaults are:
|
||||
|
||||
## Cube.js for XM Suite v5
|
||||
|
||||
This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features require an external Cube instance.
|
||||
XM Suite v5 dashboard and analysis features require Cube.js. Set `cube.enabled=true` to deploy an
|
||||
internal Cube service from this chart, or provide an external Cube endpoint.
|
||||
|
||||
- Set `deployment.env.CUBEJS_API_URL` to your Cube endpoint.
|
||||
- For chart-managed Cube, set `deployment.env.CUBEJS_API_URL` to `http://formbricks-cube:4000`
|
||||
when using the default release name.
|
||||
- For external Cube, set `deployment.env.CUBEJS_API_URL` to your Cube endpoint.
|
||||
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
|
||||
- Provide `CUBEJS_DB_*` connection variables to the Cube deployment through `cube.envFrom` or `cube.env`.
|
||||
- Keep `cube.replicas=1` while `cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER` is `memory`. Configure Cube Store before running multiple Cube replicas.
|
||||
- Keep Hub enabled. Cube should point at the same feedback records database that Hub writes to, unless you intentionally split that storage.
|
||||
|
||||
## Hub worker and self-hosted embeddings
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/* eslint-env es2022 */
|
||||
|
||||
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
|
||||
const REQUIRED_SCOPE = "xm:cube:query";
|
||||
|
||||
function assertRequiredEnvironmentVariable(name) {
|
||||
const value = process.env[name];
|
||||
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${name} is required to run Cube`);
|
||||
}
|
||||
}
|
||||
|
||||
assertRequiredEnvironmentVariable("CUBEJS_API_SECRET");
|
||||
|
||||
function getStringClaim(securityContext, claim) {
|
||||
const value = securityContext?.[claim];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
return trimmedValue.length > 0 ? trimmedValue : null;
|
||||
}
|
||||
|
||||
function getRequiredStringClaim(securityContext, claim) {
|
||||
const value = getStringClaim(securityContext, claim);
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Cube query rejected: missing ${claim} security context`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectFilterMembers(filters) {
|
||||
if (!Array.isArray(filters)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return filters.flatMap((filter) => [
|
||||
...(typeof filter?.member === "string" ? [filter.member] : []),
|
||||
...(typeof filter?.dimension === "string" ? [filter.dimension] : []),
|
||||
...collectFilterMembers(filter?.and),
|
||||
...collectFilterMembers(filter?.or),
|
||||
]);
|
||||
}
|
||||
|
||||
function collectOrderMembers(order) {
|
||||
if (!order) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(order)) {
|
||||
return order
|
||||
.map((orderEntry) => (Array.isArray(orderEntry) ? orderEntry[0] : null))
|
||||
.filter((member) => typeof member === "string");
|
||||
}
|
||||
|
||||
if (typeof order === "object") {
|
||||
return Object.keys(order);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function collectTimeDimensionMembers(timeDimensions) {
|
||||
if (!Array.isArray(timeDimensions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return timeDimensions
|
||||
.map((timeDimension) => timeDimension?.dimension)
|
||||
.filter((dimension) => typeof dimension === "string");
|
||||
}
|
||||
|
||||
function collectQueryMembers(query) {
|
||||
const cubeQuery = query ?? {};
|
||||
const members = [
|
||||
...(Array.isArray(cubeQuery.measures) ? cubeQuery.measures : []),
|
||||
...(Array.isArray(cubeQuery.dimensions) ? cubeQuery.dimensions : []),
|
||||
...(Array.isArray(cubeQuery.segments) ? cubeQuery.segments : []),
|
||||
...collectTimeDimensionMembers(cubeQuery.timeDimensions),
|
||||
...collectFilterMembers(cubeQuery.filters),
|
||||
...collectOrderMembers(cubeQuery.order),
|
||||
].filter((member) => typeof member === "string");
|
||||
|
||||
return Array.from(new Set(members)).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function assertValidSecurityContext(securityContext) {
|
||||
const tenantId = getRequiredStringClaim(securityContext, "tenantId");
|
||||
const feedbackDirectoryId = getRequiredStringClaim(securityContext, "feedbackDirectoryId");
|
||||
const workspaceId = getRequiredStringClaim(securityContext, "workspaceId");
|
||||
const scope = getRequiredStringClaim(securityContext, "scope");
|
||||
|
||||
if (scope !== REQUIRED_SCOPE) {
|
||||
throw new Error("Cube query rejected: invalid Cube query scope");
|
||||
}
|
||||
if (tenantId !== feedbackDirectoryId) {
|
||||
throw new Error("Cube query rejected: tenantId/feedbackDirectoryId mismatch");
|
||||
}
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
feedbackDirectoryId,
|
||||
workspaceId,
|
||||
organizationId: getRequiredStringClaim(securityContext, "organizationId"),
|
||||
userId: getRequiredStringClaim(securityContext, "userId"),
|
||||
requestId: getRequiredStringClaim(securityContext, "jti"),
|
||||
source: getRequiredStringClaim(securityContext, "source"),
|
||||
};
|
||||
}
|
||||
|
||||
function assertNoCallerTenantMember(query) {
|
||||
for (const member of collectQueryMembers(query)) {
|
||||
if (TENANT_MEMBERS.includes(member)) {
|
||||
throw new Error("Cube query rejected: tenant filters are enforced by Cube");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logCubeQueryAuditEvent(context, query, { error, status = "success" } = {}) {
|
||||
const errorName = error instanceof Error ? error.name : undefined;
|
||||
const errorMessage = error instanceof Error ? error.message : error ? String(error) : undefined;
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type: "audit",
|
||||
event: "cube.query",
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
tenantId: context.tenantId,
|
||||
feedbackDirectoryId: context.feedbackDirectoryId,
|
||||
workspaceId: context.workspaceId,
|
||||
organizationId: context.organizationId,
|
||||
userId: context.userId,
|
||||
requestId: context.requestId,
|
||||
source: context.source,
|
||||
members: collectQueryMembers(query),
|
||||
...(errorName ? { errorName } : {}),
|
||||
...(errorMessage ? { errorMessage } : {}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function logCubeQuerySecurityContextFailure(query, error) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type: "audit",
|
||||
event: "cube.query",
|
||||
status: "failure",
|
||||
timestamp: new Date().toISOString(),
|
||||
members: collectQueryMembers(query),
|
||||
errorName: error instanceof Error ? error.name : undefined,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function queryRewrite(query, rewriteContext) {
|
||||
const cubeQuery = query ?? {};
|
||||
let context;
|
||||
|
||||
try {
|
||||
context = assertValidSecurityContext(rewriteContext?.securityContext);
|
||||
} catch (error) {
|
||||
logCubeQuerySecurityContextFailure(cubeQuery, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
assertNoCallerTenantMember(cubeQuery);
|
||||
} catch (error) {
|
||||
logCubeQueryAuditEvent(context, cubeQuery, { error, status: "failure" });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const queriedCubePrefixes = new Set(collectQueryMembers(cubeQuery).map((member) => member.split(".")[0]));
|
||||
const rewrittenQuery = {
|
||||
...cubeQuery,
|
||||
filters: [
|
||||
...(Array.isArray(cubeQuery.filters) ? cubeQuery.filters : []),
|
||||
...TENANT_MEMBERS.filter((member) => queriedCubePrefixes.has(member.split(".")[0])).map(
|
||||
(member) => ({
|
||||
member,
|
||||
operator: "equals",
|
||||
values: [context.tenantId],
|
||||
})
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
logCubeQueryAuditEvent(context, rewrittenQuery);
|
||||
return rewrittenQuery;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queryRewrite,
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
// 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.
|
||||
cube(`FeedbackRecords`, {
|
||||
sql: `SELECT * FROM feedback_records`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
description: `Total number of feedback responses`,
|
||||
},
|
||||
|
||||
promoterCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 9` }],
|
||||
description: `Number of promoters (NPS score 9-10)`,
|
||||
},
|
||||
|
||||
detractorCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6` }],
|
||||
description: `Number of detractors (NPS score 0-6)`,
|
||||
},
|
||||
|
||||
passiveCount: {
|
||||
type: `count`,
|
||||
filters: [{ sql: `${CUBE}.value_number >= 7 AND ${CUBE}.value_number <= 8` }],
|
||||
description: `Number of passives (NPS score 7-8)`,
|
||||
},
|
||||
|
||||
npsScore: {
|
||||
type: `number`,
|
||||
sql: `
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
(
|
||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
/ COUNT(*)::numeric
|
||||
) * 100,
|
||||
2
|
||||
)
|
||||
END
|
||||
`,
|
||||
description: `Net Promoter Score: ((Promoters - Detractors) / Total) * 100`,
|
||||
},
|
||||
|
||||
averageScore: {
|
||||
type: `avg`,
|
||||
sql: `${CUBE}.value_number`,
|
||||
description: `Average NPS score`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `id`,
|
||||
type: `string`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
sentiment: {
|
||||
sql: `sentiment`,
|
||||
type: `string`,
|
||||
description: `Sentiment extracted from metadata JSONB field`,
|
||||
},
|
||||
|
||||
sourceType: {
|
||||
sql: `source_type`,
|
||||
type: `string`,
|
||||
description: `Source type of the feedback (e.g., nps_campaign, survey)`,
|
||||
},
|
||||
|
||||
sourceName: {
|
||||
sql: `source_name`,
|
||||
type: `string`,
|
||||
description: `Human-readable name of the source`,
|
||||
},
|
||||
|
||||
fieldType: {
|
||||
sql: `field_type`,
|
||||
type: `string`,
|
||||
description: `Type of feedback field (e.g., nps, text, rating)`,
|
||||
},
|
||||
|
||||
collectedAt: {
|
||||
sql: `collected_at`,
|
||||
type: `time`,
|
||||
description: `Timestamp when the feedback was collected`,
|
||||
},
|
||||
|
||||
npsValue: {
|
||||
sql: `value_number`,
|
||||
type: `number`,
|
||||
description: `Raw NPS score value (0-10)`,
|
||||
},
|
||||
|
||||
responseId: {
|
||||
sql: `response_id`,
|
||||
type: `string`,
|
||||
description: `Unique identifier linking related feedback records`,
|
||||
},
|
||||
|
||||
userId: {
|
||||
sql: `user_id`,
|
||||
type: `string`,
|
||||
description: `Identifier of the user who provided feedback`,
|
||||
},
|
||||
|
||||
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`,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -15,6 +15,14 @@ Hub resource name: base name truncated to 59 chars then "-hub" so the suffix is
|
||||
{{- printf "%s-hub" $base | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Cube.js resource name.
|
||||
*/}}
|
||||
{{- define "formbricks.cubeName" -}}
|
||||
{{- $base := include "formbricks.name" . | trunc 58 | trimSuffix "-" }}
|
||||
{{- printf "%s-cube" $base | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
{{/*
|
||||
Define the application version to be used in labels.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{{- if .Values.cube.enabled }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "formbricks.cubeName" . }}-config
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: cube
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
data:
|
||||
cube.js: |-
|
||||
{{ .Files.Get "cube/cube.js" | indent 4 }}
|
||||
FeedbackRecords.js: |-
|
||||
{{ .Files.Get "cube/schema/FeedbackRecords.js" | indent 4 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,124 @@
|
||||
{{- $cubeCacheDriver := get (.Values.cube.env | default dict) "CUBEJS_CACHE_AND_QUEUE_DRIVER" | default "" | toString | trim | lower }}
|
||||
{{- if and .Values.cube.enabled (gt (int .Values.cube.replicas) 1) (eq $cubeCacheDriver "memory") }}
|
||||
{{- fail "cube.env.CUBEJS_CACHE_AND_QUEUE_DRIVER=memory is only supported when cube.replicas=1. Use Cube Store for multiple Cube replicas." }}
|
||||
{{- end }}
|
||||
{{- if .Values.cube.enabled }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "formbricks.cubeName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: cube
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
spec:
|
||||
replicas: {{ .Values.cube.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: cube
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/cube-configmap.yaml") . | sha256sum }}
|
||||
spec:
|
||||
{{- if .Values.cube.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.cube.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.cube.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.cube.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.cube.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.cube.topologySpreadConstraints }}
|
||||
topologySpreadConstraints:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: cube
|
||||
image: "{{ .Values.cube.image.repository }}:{{ .Values.cube.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.cube.image.pullPolicy }}
|
||||
{{- with .Values.cube.containerSecurityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.cube.port }}
|
||||
protocol: TCP
|
||||
{{- with .Values.cube.livenessProbe }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.cube.readinessProbe }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.cube.envFrom }}
|
||||
envFrom:
|
||||
{{- range $value := .Values.cube.envFrom }}
|
||||
{{- if (eq .type "configmap") }}
|
||||
- configMapRef:
|
||||
{{- if .name }}
|
||||
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
|
||||
{{- else if .nameSuffix }}
|
||||
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
|
||||
{{- else }}
|
||||
name: {{ template "formbricks.name" $ }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if (eq .type "secret") }}
|
||||
- secretRef:
|
||||
{{- if .name }}
|
||||
name: {{ include "formbricks.tplvalues.render" ( dict "value" $value.name "context" $ ) }}
|
||||
{{- else if .nameSuffix }}
|
||||
name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" ( dict "value" $value.nameSuffix "context" $ ) }}
|
||||
{{- else }}
|
||||
name: {{ template "formbricks.name" $ }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- range $key, $value := .Values.cube.env }}
|
||||
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
|
||||
{{- if kindIs "string" $value }}
|
||||
value: {{ include "formbricks.tplvalues.render" ( dict "value" $value "context" $ ) | quote }}
|
||||
{{- else }}
|
||||
{{- toYaml $value | nindent 14 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: cube-config
|
||||
mountPath: /cube/conf/cube.js
|
||||
subPath: cube.js
|
||||
readOnly: true
|
||||
- name: cube-config
|
||||
mountPath: /cube/conf/model/FeedbackRecords.js
|
||||
subPath: FeedbackRecords.js
|
||||
readOnly: true
|
||||
{{- if .Values.cube.resources }}
|
||||
resources:
|
||||
{{- toYaml .Values.cube.resources | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: cube-config
|
||||
configMap:
|
||||
name: {{ include "formbricks.cubeName" . }}-config
|
||||
{{- end }}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{- if .Values.cube.enabled }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "formbricks.cubeName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: cube
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
spec:
|
||||
type: {{ .Values.cube.service.type }}
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ include "formbricks.cubeName" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.cube.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
@@ -39,6 +39,7 @@ spec:
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -55,6 +56,7 @@ spec:
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
|
||||
@@ -54,6 +54,7 @@ spec:
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -79,6 +80,7 @@ spec:
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "formbricks.hubSecretName" . }}
|
||||
|
||||
@@ -557,6 +557,75 @@ serviceMonitor:
|
||||
path: /metrics
|
||||
port: metrics
|
||||
|
||||
##########################################################
|
||||
# Cube.js Analytics Configuration
|
||||
##########################################################
|
||||
cube:
|
||||
# Optional internal Cube.js service for XM Suite v5 analytics.
|
||||
enabled: false
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: "cubejs/cube"
|
||||
tag: "v1.6.6"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
port: 4000
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 4000
|
||||
|
||||
# Secret values such as CUBEJS_API_SECRET and CUBEJS_DB_* should be supplied
|
||||
# through envFrom or another secret-management flow.
|
||||
envFrom: []
|
||||
|
||||
env:
|
||||
CUBEJS_DB_TYPE: "postgres"
|
||||
CUBEJS_DEFAULT_API_SCOPES: "meta,data"
|
||||
# Keep the in-memory cache/queue driver at one Cube replica only. The chart
|
||||
# fails rendering when this remains "memory" and cube.replicas is greater than 1.
|
||||
CUBEJS_CACHE_AND_QUEUE_DRIVER: "memory"
|
||||
CUBEJS_JWT_ISSUER: "formbricks-web"
|
||||
CUBEJS_JWT_AUDIENCE: "formbricks-cube"
|
||||
|
||||
containerSecurityContext:
|
||||
readOnlyRootFilesystem: false
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
|
||||
resources:
|
||||
limits:
|
||||
memory: 1Gi
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: "250m"
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
topologySpreadConstraints: []
|
||||
|
||||
##########################################################
|
||||
# Hub API Configuration
|
||||
# Formbricks Hub image: ghcr.io/formbricks/hub
|
||||
@@ -571,10 +640,10 @@ hub:
|
||||
# Pinned by digest for immutable, reproducible deployments. When digest is set it takes
|
||||
# precedence over tag, and deployment, init container, and migration job all resolve to the
|
||||
# same immutable image. Update on each Hub release.
|
||||
# Current digest corresponds to ghcr.io/formbricks/hub:0.2.0.
|
||||
digest: "sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"
|
||||
# Current digest corresponds to ghcr.io/formbricks/hub:0.3.0.
|
||||
digest: "sha256:6c39b1143527137e881df785a5b668625a1fe3edb05485bb5ded19f813c8de88"
|
||||
# Tag is a fallback for dev/non-prod when digest is cleared; keep aligned with the digest above.
|
||||
tag: "0.2.0"
|
||||
tag: "0.3.0"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional override for the secret Hub reads from.
|
||||
@@ -713,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
|
||||
@@ -830,8 +897,9 @@ hub:
|
||||
affinity: {}
|
||||
topologySpreadConstraints: []
|
||||
|
||||
# Helm does not deploy Cube. XM Suite v5 analytics requires operators to provide an external Cube instance,
|
||||
# set deployment.env.CUBEJS_API_URL, and supply CUBEJS_API_SECRET via an existing secret.
|
||||
# XM Suite v5 analytics also requires Cube. Use cube.enabled=true to deploy
|
||||
# the internal chart-managed Cube service, or set deployment.env.CUBEJS_API_URL
|
||||
# to an operator-managed Cube endpoint.
|
||||
|
||||
# Upgrade migration job runs goose + river before Helm upgrades Hub resources.
|
||||
# Fresh installs run the same migrations through the Hub deployment init container.
|
||||
|
||||
+34
-8
@@ -1,6 +1,6 @@
|
||||
/* eslint-env es2022 */
|
||||
|
||||
const TENANT_MEMBER = "FeedbackRecords.tenantId";
|
||||
const TENANT_MEMBERS = ["FeedbackRecords.tenantId", "TopicsUnnested.tenantId"];
|
||||
const REQUIRED_SCOPE = "xm:cube:query";
|
||||
|
||||
function assertRequiredEnvironmentVariable(name) {
|
||||
@@ -114,7 +114,7 @@ function assertValidSecurityContext(securityContext) {
|
||||
|
||||
function assertNoCallerTenantMember(query) {
|
||||
for (const member of collectQueryMembers(query)) {
|
||||
if (member === TENANT_MEMBER) {
|
||||
if (TENANT_MEMBERS.includes(member)) {
|
||||
throw new Error("Cube query rejected: tenant filters are enforced by Cube");
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ function assertNoCallerTenantMember(query) {
|
||||
|
||||
function logCubeQueryAuditEvent(context, query, { error, status = "success" } = {}) {
|
||||
const errorName = error instanceof Error ? error.name : undefined;
|
||||
const errorMessage = error instanceof Error ? error.message : error ? String(error) : undefined;
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
@@ -138,13 +139,35 @@ function logCubeQueryAuditEvent(context, query, { error, status = "success" } =
|
||||
source: context.source,
|
||||
members: collectQueryMembers(query),
|
||||
...(errorName ? { errorName } : {}),
|
||||
...(errorMessage ? { errorMessage } : {}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function logCubeQuerySecurityContextFailure(query, error) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type: "audit",
|
||||
event: "cube.query",
|
||||
status: "failure",
|
||||
timestamp: new Date().toISOString(),
|
||||
members: collectQueryMembers(query),
|
||||
errorName: error instanceof Error ? error.name : undefined,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function queryRewrite(query, rewriteContext) {
|
||||
const cubeQuery = query ?? {};
|
||||
const context = assertValidSecurityContext(rewriteContext?.securityContext);
|
||||
let context;
|
||||
|
||||
try {
|
||||
context = assertValidSecurityContext(rewriteContext?.securityContext);
|
||||
} catch (error) {
|
||||
logCubeQuerySecurityContextFailure(cubeQuery, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
assertNoCallerTenantMember(cubeQuery);
|
||||
@@ -153,15 +176,18 @@ function queryRewrite(query, rewriteContext) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const queriedCubePrefixes = new Set(collectQueryMembers(cubeQuery).map((member) => member.split(".")[0]));
|
||||
const rewrittenQuery = {
|
||||
...cubeQuery,
|
||||
filters: [
|
||||
...(Array.isArray(cubeQuery.filters) ? cubeQuery.filters : []),
|
||||
{
|
||||
member: TENANT_MEMBER,
|
||||
operator: "equals",
|
||||
values: [context.tenantId],
|
||||
},
|
||||
...TENANT_MEMBERS.filter((member) => queriedCubePrefixes.has(member.split(".")[0])).map(
|
||||
(member) => ({
|
||||
member,
|
||||
operator: "equals",
|
||||
values: [context.tenantId],
|
||||
})
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ cube(`FeedbackRecords`, {
|
||||
ELSE ROUND(
|
||||
(
|
||||
(COUNT(CASE WHEN ${CUBE}.value_number >= 9 THEN 1 END)::numeric -
|
||||
COUNT(CASE WHEN ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
COUNT(CASE WHEN ${CUBE}.value_number >= 0 AND ${CUBE}.value_number <= 6 THEN 1 END)::numeric)
|
||||
/ COUNT(*)::numeric
|
||||
) * 100,
|
||||
2
|
||||
@@ -61,7 +61,7 @@ cube(`FeedbackRecords`, {
|
||||
},
|
||||
|
||||
sentiment: {
|
||||
sql: `sentiment`,
|
||||
sql: `${CUBE}.metadata->>'sentiment'`,
|
||||
type: `string`,
|
||||
description: `Sentiment extracted from metadata JSONB field`,
|
||||
},
|
||||
@@ -97,9 +97,9 @@ cube(`FeedbackRecords`, {
|
||||
},
|
||||
|
||||
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: {
|
||||
@@ -109,7 +109,7 @@ cube(`FeedbackRecords`, {
|
||||
},
|
||||
|
||||
emotion: {
|
||||
sql: `emotion`,
|
||||
sql: `${CUBE}.metadata->>'emotion'`,
|
||||
type: `string`,
|
||||
description: `Emotion extracted from metadata JSONB field`,
|
||||
},
|
||||
@@ -133,6 +133,7 @@ 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)
|
||||
@@ -156,6 +157,12 @@ cube(`TopicsUnnested`, {
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
tenantId: {
|
||||
sql: `tenant_id`,
|
||||
type: `string`,
|
||||
description: `Tenant ID for row-level security scoping`,
|
||||
},
|
||||
|
||||
topic: {
|
||||
sql: `topic`,
|
||||
type: `string`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { JobSchedulerTemplateOptions, JobsOptions } from "bullmq";
|
||||
|
||||
export const JOBS_QUEUE_NAME = "background-jobs";
|
||||
export const JOBS_PREFIX = "formbricks:jobs";
|
||||
export const JOBS_PREFIX = "{formbricks:jobs}";
|
||||
|
||||
export const JOB_NAMES = {
|
||||
testLog: "system.test-log",
|
||||
|
||||
@@ -132,6 +132,10 @@ describe("@formbricks/jobs queue helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("uses a Redis Cluster hash-tagged prefix for BullMQ keys", () => {
|
||||
expect(JOBS_PREFIX).toBe("{formbricks:jobs}");
|
||||
});
|
||||
|
||||
test("memoizes the producer queue", async () => {
|
||||
const first = await getJobsQueue();
|
||||
const second = await getJobsQueue();
|
||||
|
||||
Reference in New Issue
Block a user