mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-18 19:41:17 -05:00
feat: feedback record directories (#7592)
This commit is contained in:
@@ -138,6 +138,12 @@ export const OrganizationBreadcrumb = ({
|
||||
label: t("common.members_and_teams"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("environments.settings.feedback_record_directories.nav_label"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/feedback-record-directories`,
|
||||
hidden: isMember,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
|
||||
@@ -40,6 +40,13 @@ export const OrganizationSettingsNavbar = ({
|
||||
href: `/environments/${environmentId}/settings/teams`,
|
||||
current: pathname?.includes("/teams"),
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("environments.settings.feedback_record_directories.nav_label"),
|
||||
href: `/environments/${environmentId}/settings/feedback-record-directories`,
|
||||
current: pathname?.includes("/feedback-record-directories"),
|
||||
hidden: isMember,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";
|
||||
@@ -91,6 +91,7 @@ checksums:
|
||||
common/action: c92af0bdf1698b0d10cf5b28d2ad4945
|
||||
common/actions: c46571856723b03262fd33f511116298
|
||||
common/actions_description: 8e35b1538d1006fa8470183310ad21ef
|
||||
common/active: 3e1ec025c4a50830bbb9ad57a176630a
|
||||
common/active_surveys: 95bf0fd5d2bf62cdd010b7cf66795ed7
|
||||
common/activity: 1948763de8e531483a798b68195e297e
|
||||
common/add: 87c4a663507f2bcbbf79934af8164e13
|
||||
@@ -112,6 +113,7 @@ checksums:
|
||||
common/app: 77e32ac4e5a1e01bc9a6a15fdfef9bf8
|
||||
common/app_survey: f076d131d20bfdadb35fba29c8275232
|
||||
common/apply_filters: 6543c1e80038b3da0f4a42848d08d4d1
|
||||
common/archived: cf5127ecfd7e43a35466a1ba5fe16450
|
||||
common/are_you_sure: 6d5cd13628a7887711fd0c29f1123652
|
||||
common/attributes: 86d0ae6fea0fbb119722ed3841f8385a
|
||||
common/back: f541015a827e37cb3b1234e56bc2aa3c
|
||||
@@ -809,8 +811,14 @@ checksums:
|
||||
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
|
||||
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
|
||||
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
|
||||
environments/integrations/webhooks/endpoint_bad_gateway_error: 48ab17e9a77030b289ec22f497f50b63
|
||||
environments/integrations/webhooks/endpoint_gateway_timeout_error: 5da45e2f6933927d1f8b0aaa9566e6a6
|
||||
environments/integrations/webhooks/endpoint_internal_server_error: 6773fc34349febf95475cde88d8ee072
|
||||
environments/integrations/webhooks/endpoint_method_not_allowed_error: 9963b503311393f4d7bffae9df46d422
|
||||
environments/integrations/webhooks/endpoint_not_found_error: 607b75b7b7aa92ca81fe44e466f7c318
|
||||
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
|
||||
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
|
||||
environments/integrations/webhooks/endpoint_service_unavailable_error: f9d4874c322f2963f5afaede354c9416
|
||||
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
|
||||
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
|
||||
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
|
||||
@@ -1067,6 +1075,32 @@ checksums:
|
||||
environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112
|
||||
environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea
|
||||
environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43
|
||||
environments/settings/feedback_record_directories/archive: fa813ab3074103e5daad07462af25789
|
||||
environments/settings/feedback_record_directories/archive_directory: 1114a8e1c5c5af3d30a174b28582f424
|
||||
environments/settings/feedback_record_directories/archive_not_allowed: 3ffe3336572a633406858887de60a470
|
||||
environments/settings/feedback_record_directories/are_you_sure_you_want_to_archive: d249e6e8bc0345835a13f70856eb1c30
|
||||
environments/settings/feedback_record_directories/assign_workspaces_description: 6c3f0bbf3bd7744bb313f4cd7886e184
|
||||
environments/settings/feedback_record_directories/create_feedback_directory: c178dd6dbd702398df3ac08a9fa43324
|
||||
environments/settings/feedback_record_directories/description: 8f56b169cb38d8c7b2697bf3a3ed7a61
|
||||
environments/settings/feedback_record_directories/directory_archived_successfully: fba5b99ced59d0546c8f2241c092a5dd
|
||||
environments/settings/feedback_record_directories/directory_created_successfully: 5db20153b840d91842543f71cdd91043
|
||||
environments/settings/feedback_record_directories/directory_id: 933a1376d7d8a8dc41ded90ef1c0f619
|
||||
environments/settings/feedback_record_directories/directory_name: 353de006e16451bf64da469a81fbe451
|
||||
environments/settings/feedback_record_directories/directory_settings_description: 895d890b4292effa5ade45fe7164990f
|
||||
environments/settings/feedback_record_directories/directory_settings_title: c32af860e3254dea9dfaeefc7cd92d49
|
||||
environments/settings/feedback_record_directories/directory_unarchived_successfully: 08d56e260decc62fe664b50ab774b728
|
||||
environments/settings/feedback_record_directories/directory_updated_successfully: 638cb6c92f535328d809274cf2be4d7d
|
||||
environments/settings/feedback_record_directories/empty_state: 665593dcb7cfa081a3e719677d0f6b0d
|
||||
environments/settings/feedback_record_directories/enter_directory_name: a1c950988199bb4c4e014dcf430cce41
|
||||
environments/settings/feedback_record_directories/error_directory_name_duplicate: 349d650f562cff96b084787126323ca2
|
||||
environments/settings/feedback_record_directories/error_directory_name_required: 0f42d7292979006a1069063ab213b8e3
|
||||
environments/settings/feedback_record_directories/error_directory_projects_invalid_org: 477b5c1a466c4194668544ffd42ec9bf
|
||||
environments/settings/feedback_record_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
|
||||
environments/settings/feedback_record_directories/no_access: cc3385cd01a11e3949003a2cc6fb5b31
|
||||
environments/settings/feedback_record_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
|
||||
environments/settings/feedback_record_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
|
||||
environments/settings/feedback_record_directories/title: e3d425c27f80162f29ce094e31a3fd8f
|
||||
environments/settings/feedback_record_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
|
||||
environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
|
||||
environments/settings/general/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba
|
||||
environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b
|
||||
@@ -1608,6 +1642,8 @@ checksums:
|
||||
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
|
||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
|
||||
environments/surveys/edit/reverse_order_occasionally: 170fd50de940f382fa2e605228e4e088
|
||||
environments/surveys/edit/reverse_order_occasionally_except_last: 1c833001b940f1419dd7534b199a0b4a
|
||||
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
|
||||
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
|
||||
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
|
||||
|
||||
@@ -22,6 +22,7 @@ export type AuditLoggingCtx = {
|
||||
quotaId?: string;
|
||||
teamId?: string;
|
||||
integrationId?: string;
|
||||
feedbackRecordDirectoryId?: string;
|
||||
};
|
||||
|
||||
export type ActionClientCtx = {
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "Aktion",
|
||||
"actions": "Aktionen",
|
||||
"actions_description": "Code- und No-Code-Aktionen werden verwendet, um Abfangumfragen innerhalb von Apps und auf Websites auszulösen.",
|
||||
"active": "Aktiv",
|
||||
"active_surveys": "Aktive Umfragen",
|
||||
"activity": "Aktivität",
|
||||
"add": "Hinzufügen",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "App",
|
||||
"app_survey": "App-Umfrage",
|
||||
"apply_filters": "Filter anwenden",
|
||||
"archived": "Archiviert",
|
||||
"are_you_sure": "Bist Du sicher?",
|
||||
"attributes": "Attribute",
|
||||
"back": "Zurück",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "Teams & Zugriffskontrolle (Lesen, Lesen & Schreiben, Verwalten)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos."
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "Archivieren",
|
||||
"archive_directory": "Verzeichnis archivieren",
|
||||
"archive_not_allowed": "Du darfst dieses Verzeichnis nicht archivieren.",
|
||||
"are_you_sure_you_want_to_archive": "Möchtest du dieses Verzeichnis wirklich archivieren? Workspaces haben dann keinen Zugriff mehr darauf.",
|
||||
"assign_workspaces_description": "Lege fest, welche Workspaces auf dieses Feedback-Verzeichnis zugreifen können.",
|
||||
"create_feedback_directory": "Feedback-Verzeichnis erstellen",
|
||||
"description": "Verwalte Feedback-Verzeichnisse und ihre Workspace-Zuordnungen.",
|
||||
"directory_archived_successfully": "Verzeichnis erfolgreich archiviert",
|
||||
"directory_created_successfully": "Verzeichnis erfolgreich erstellt",
|
||||
"directory_id": "Verzeichnis-ID",
|
||||
"directory_name": "Verzeichnisname",
|
||||
"directory_settings_description": "Verwalte Verzeichnisnamen, Workspace-Zuordnungen und mehr.",
|
||||
"directory_settings_title": "Einstellungen für {directoryName}",
|
||||
"directory_unarchived_successfully": "Archivierung des Verzeichnisses erfolgreich aufgehoben",
|
||||
"directory_updated_successfully": "Verzeichnis erfolgreich aktualisiert",
|
||||
"empty_state": "Keine Feedback-Verzeichnisse gefunden. Erstelle eins, um loszulegen.",
|
||||
"enter_directory_name": "Verzeichnisnamen eingeben",
|
||||
"error_directory_name_duplicate": "Ein Feedback-Verzeichnis mit diesem Namen existiert bereits.",
|
||||
"error_directory_name_required": "Verzeichnisname ist erforderlich.",
|
||||
"error_directory_projects_invalid_org": "Einige der angegebenen Workspaces gehören nicht zu dieser Organisation.",
|
||||
"nav_label": "Feedback-Verzeichnisse",
|
||||
"no_access": "Du hast keine Berechtigung, Feedback-Verzeichnisse zu verwalten.",
|
||||
"select_workspaces_placeholder": "Arbeitsbereiche auswählen...",
|
||||
"show_archived": "Archivierte anzeigen",
|
||||
"title": "Feedback-Aufzeichnungsverzeichnisse",
|
||||
"unarchive": "Aus Archiv wiederherstellen"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "Bitte beachte, dass im Free-Plan alle Organisationsmitglieder automatisch die Rolle \"Owner\" zugewiesen bekommen, unabhängig von der im CSV-File angegebenen Rolle.",
|
||||
"cannot_delete_only_organization": "Das ist deine einzige Organisation, sie kann nicht gelöscht werden. Erstelle zuerst eine neue Organisation.",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "Action",
|
||||
"actions": "Actions",
|
||||
"actions_description": "Code and No-Code Actions are used to trigger intercept surveys within apps & on websites.",
|
||||
"active": "Active",
|
||||
"active_surveys": "Active surveys",
|
||||
"activity": "Activity",
|
||||
"add": "Add",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "App",
|
||||
"app_survey": "App Survey",
|
||||
"apply_filters": "Apply filters",
|
||||
"archived": "Archived",
|
||||
"are_you_sure": "Are you sure?",
|
||||
"attributes": "Attributes",
|
||||
"back": "Back",
|
||||
@@ -261,11 +263,11 @@
|
||||
"invalid_file_type": "Invalid file type",
|
||||
"invite": "Invite",
|
||||
"invite_them": "Invite them",
|
||||
"javascript_required": "JavaScript Required",
|
||||
"javascript_required_description": "Formbricks requires JavaScript to function properly. Please enable JavaScript in your browser settings to continue.",
|
||||
"key": "Key",
|
||||
"label": "Label",
|
||||
"language": "Language",
|
||||
"javascript_required": "JavaScript Required",
|
||||
"javascript_required_description": "Formbricks requires JavaScript to function properly. Please enable JavaScript in your browser settings to continue.",
|
||||
"last_name": "Last Name",
|
||||
"learn_more": "Learn more",
|
||||
"license_expired": "License Expired",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "Teams & Access Roles (Read, Read & Write, Manage)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Unlock the full power of Formbricks. Free for 30 days."
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "Archive",
|
||||
"archive_directory": "Archive Directory",
|
||||
"archive_not_allowed": "You are not allowed to archive this directory.",
|
||||
"are_you_sure_you_want_to_archive": "Are you sure you want to archive this directory? Workspaces will no longer have access to it.",
|
||||
"assign_workspaces_description": "Control which workspaces can access this feedback record directory.",
|
||||
"create_feedback_directory": "Create feedback directory",
|
||||
"description": "Manage feedback record directories and their workspace assignments.",
|
||||
"directory_archived_successfully": "Directory archived successfully",
|
||||
"directory_created_successfully": "Directory created successfully",
|
||||
"directory_id": "Directory ID",
|
||||
"directory_name": "Directory Name",
|
||||
"directory_settings_description": "Manage directory name, workspace assignments, and more.",
|
||||
"directory_settings_title": "{directoryName} Settings",
|
||||
"directory_unarchived_successfully": "Directory unarchived successfully",
|
||||
"directory_updated_successfully": "Directory updated successfully",
|
||||
"empty_state": "No feedback record directories found. Create one to get started.",
|
||||
"enter_directory_name": "Enter directory name",
|
||||
"error_directory_name_duplicate": "A feedback record directory with this name already exists.",
|
||||
"error_directory_name_required": "Directory name is required.",
|
||||
"error_directory_projects_invalid_org": "Some specified workspaces do not belong to this organization.",
|
||||
"nav_label": "Feedback Directories",
|
||||
"no_access": "You do not have permission to manage feedback record directories.",
|
||||
"select_workspaces_placeholder": "Select workspaces...",
|
||||
"show_archived": "Show archived",
|
||||
"title": "Feedback Record Directories",
|
||||
"unarchive": "Unarchive"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "On the free plan, all organization members are always assigned the “Owner” role.",
|
||||
"cannot_delete_only_organization": "This is your only organization, it cannot be deleted. Create a new organization first.",
|
||||
@@ -3354,4 +3384,4 @@
|
||||
"thank_you_description": "Your input helps us build the Workflows feature you actually need. We will keep you posted on our progress.",
|
||||
"thank_you_title": "Thank you for your feedback!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "Acción",
|
||||
"actions": "Acciones",
|
||||
"actions_description": "Las acciones de código y sin código se utilizan para activar encuestas de intercepción en aplicaciones y sitios web.",
|
||||
"active": "Activo",
|
||||
"active_surveys": "Encuestas activas",
|
||||
"activity": "Actividad",
|
||||
"add": "Añadir",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "Aplicación",
|
||||
"app_survey": "Encuesta de aplicación",
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"archived": "Archivado",
|
||||
"are_you_sure": "¿Estás seguro?",
|
||||
"attributes": "Atributos",
|
||||
"back": "Atrás",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "Equipos y roles de acceso (lectura, lectura y escritura, gestión)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días."
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "Archivar",
|
||||
"archive_directory": "Archivar Directorio",
|
||||
"archive_not_allowed": "No tienes permiso para archivar este directorio.",
|
||||
"are_you_sure_you_want_to_archive": "¿Estás seguro de que quieres archivar este directorio? Los espacios de trabajo ya no tendrán acceso a él.",
|
||||
"assign_workspaces_description": "Controla qué espacios de trabajo pueden acceder a este directorio de registros de feedback.",
|
||||
"create_feedback_directory": "Crear directorio de comentarios",
|
||||
"description": "Gestiona los directorios de registros de feedback y sus asignaciones de espacios de trabajo.",
|
||||
"directory_archived_successfully": "Directorio archivado correctamente",
|
||||
"directory_created_successfully": "Directorio creado correctamente",
|
||||
"directory_id": "ID del Directorio",
|
||||
"directory_name": "Nombre del Directorio",
|
||||
"directory_settings_description": "Gestiona el nombre del directorio, las asignaciones de espacios de trabajo y más.",
|
||||
"directory_settings_title": "Configuración de {directoryName}",
|
||||
"directory_unarchived_successfully": "Directorio desarchivado correctamente",
|
||||
"directory_updated_successfully": "Directorio actualizado correctamente",
|
||||
"empty_state": "No se encontraron directorios de registros de feedback. Crea uno para empezar.",
|
||||
"enter_directory_name": "Introduce el nombre del directorio",
|
||||
"error_directory_name_duplicate": "Ya existe un directorio de registros de comentarios con este nombre.",
|
||||
"error_directory_name_required": "El nombre del directorio es obligatorio.",
|
||||
"error_directory_projects_invalid_org": "Algunos de los espacios de trabajo especificados no pertenecen a esta organización.",
|
||||
"nav_label": "Directorios de Feedback",
|
||||
"no_access": "No tienes permiso para gestionar los directorios de registros de feedback.",
|
||||
"select_workspaces_placeholder": "Selecciona espacios de trabajo...",
|
||||
"show_archived": "Mostrar archivados",
|
||||
"title": "Directorios de Registros de Feedback",
|
||||
"unarchive": "Desarchivar"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".",
|
||||
"cannot_delete_only_organization": "Esta es tu única organización, no se puede eliminar. Crea una nueva organización primero.",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "Action",
|
||||
"actions": "Actions",
|
||||
"actions_description": "Les actions avec et sans code permettent de déclencher des enquêtes dans des applications et sur des sites Web.",
|
||||
"active": "Actif",
|
||||
"active_surveys": "Sondages actifs",
|
||||
"activity": "Activité",
|
||||
"add": "Ajouter",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "Application",
|
||||
"app_survey": "Sondage d'application",
|
||||
"apply_filters": "Appliquer des filtres",
|
||||
"archived": "Archivé",
|
||||
"are_you_sure": "Es-tu sûr ?",
|
||||
"attributes": "Attributs",
|
||||
"back": "Retour",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "Équipes et Rôles d'Accès (Lire, Lire et Écrire, Gérer)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Débloquez tout le potentiel de Formbricks. Gratuit pendant 30 jours."
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "Archiver",
|
||||
"archive_directory": "Archiver le répertoire",
|
||||
"archive_not_allowed": "Vous n'êtes pas autorisé à archiver ce répertoire.",
|
||||
"are_you_sure_you_want_to_archive": "Es-tu sûr de vouloir archiver ce répertoire ? Les espaces de travail n'y auront plus accès.",
|
||||
"assign_workspaces_description": "Contrôle quels espaces de travail peuvent accéder à ce répertoire de feedback.",
|
||||
"create_feedback_directory": "Créer un répertoire de commentaires",
|
||||
"description": "Gère les répertoires de feedback et leurs affectations aux espaces de travail.",
|
||||
"directory_archived_successfully": "Répertoire archivé avec succès",
|
||||
"directory_created_successfully": "Répertoire créé avec succès",
|
||||
"directory_id": "ID du répertoire",
|
||||
"directory_name": "Nom du répertoire",
|
||||
"directory_settings_description": "Gère le nom du répertoire, les affectations aux espaces de travail et plus encore.",
|
||||
"directory_settings_title": "Paramètres de {directoryName}",
|
||||
"directory_unarchived_successfully": "Répertoire désarchivé avec succès",
|
||||
"directory_updated_successfully": "Répertoire mis à jour avec succès",
|
||||
"empty_state": "Aucun répertoire de feedback trouvé. Crée-en un pour commencer.",
|
||||
"enter_directory_name": "Saisir le nom du répertoire",
|
||||
"error_directory_name_duplicate": "Un répertoire d'enregistrement de feedback avec ce nom existe déjà.",
|
||||
"error_directory_name_required": "Le nom du répertoire est requis.",
|
||||
"error_directory_projects_invalid_org": "Certains espaces de travail spécifiés n'appartiennent pas à cette organisation.",
|
||||
"nav_label": "Répertoires de feedback",
|
||||
"no_access": "Tu n'as pas la permission de gérer les répertoires de feedback.",
|
||||
"select_workspaces_placeholder": "Sélectionner des espaces de travail...",
|
||||
"show_archived": "Afficher les éléments archivés",
|
||||
"title": "Répertoires d'enregistrement des retours",
|
||||
"unarchive": "Désarchiver"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "Dans le plan gratuit, tous les membres de l'organisation se voient toujours attribuer le rôle \"Owner\".",
|
||||
"cannot_delete_only_organization": "C'est votre seule organisation, elle ne peut pas être supprimée. Créez d'abord une nouvelle organisation.",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "Művelet",
|
||||
"actions": "Műveletek",
|
||||
"actions_description": "A kód vagy kód nélküli műveleteket arra használják, hogy aktiválják a kérdőívek alkalmazásokon és webhelyeken belüli elfogását.",
|
||||
"active": "Aktív",
|
||||
"active_surveys": "Aktív kérdőívek",
|
||||
"activity": "Tevékenység",
|
||||
"add": "Hozzáadás",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "Alkalmazás",
|
||||
"app_survey": "Alkalmazás-kérdőív",
|
||||
"apply_filters": "Szűrők alkalmazása",
|
||||
"archived": "Archivált",
|
||||
"are_you_sure": "Biztos benne?",
|
||||
"attributes": "Attribútumok",
|
||||
"back": "Vissza",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "Csapatok és hozzáférési szerepek (olvasás, olvasás és írás, kezelés)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen."
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "Archiválás",
|
||||
"archive_directory": "Könyvtár archiválása",
|
||||
"archive_not_allowed": "Nem rendelkezik jogosultsággal ezen könyvtár archiválásához.",
|
||||
"are_you_sure_you_want_to_archive": "Biztosan archiválni kívánja ezt a könyvtárat? A munkaterületek többé nem férhetnek hozzá.",
|
||||
"assign_workspaces_description": "Szabályozza, mely munkaterületek férhetnek hozzá ehhez a visszajelzési nyilvántartási könyvtárhoz.",
|
||||
"create_feedback_directory": "Visszajelzési könyvtár létrehozása",
|
||||
"description": "Visszajelzési nyilvántartási könyvtárak és munkaterület-hozzárendeléseik kezelése.",
|
||||
"directory_archived_successfully": "A könyvtár sikeresen archiválva",
|
||||
"directory_created_successfully": "A könyvtár sikeresen létrehozva",
|
||||
"directory_id": "Könyvtár azonosító",
|
||||
"directory_name": "Könyvtár neve",
|
||||
"directory_settings_description": "Könyvtár nevének, munkaterület-hozzárendeléseinek és egyéb beállítások kezelése.",
|
||||
"directory_settings_title": "{directoryName} beállításai",
|
||||
"directory_unarchived_successfully": "A könyvtár archiválása sikeresen visszavonva",
|
||||
"directory_updated_successfully": "A könyvtár sikeresen frissítve",
|
||||
"empty_state": "Nem található visszajelzési nyilvántartási könyvtár. Hozzon létre egyet a kezdéshez.",
|
||||
"enter_directory_name": "Adja meg a könyvtár nevét",
|
||||
"error_directory_name_duplicate": "Ezzel a névvel már létezik visszajelzési rekord könyvtár.",
|
||||
"error_directory_name_required": "A könyvtár neve kötelező megadni.",
|
||||
"error_directory_projects_invalid_org": "Egyes megadott munkaterületek nem ehhez a szervezethez tartoznak.",
|
||||
"nav_label": "Visszajelzési könyvtárak",
|
||||
"no_access": "Nem rendelkezik jogosultsággal a visszajelzési nyilvántartási könyvtárak kezeléséhez.",
|
||||
"select_workspaces_placeholder": "Munkaterületek kiválasztása...",
|
||||
"show_archived": "Archivált elemek megjelenítése",
|
||||
"title": "Visszajelzési Nyilvántartási Könyvtárak",
|
||||
"unarchive": "Archiválás visszavonása"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.",
|
||||
"cannot_delete_only_organization": "Ez az egyetlen szervezete, nem lehet törölni. Először hozzon létre egy új szervezetet.",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "アクション",
|
||||
"actions": "アクション",
|
||||
"actions_description": "コードとノーコードアクションは、アプリ内やウェブサイト上で調査を発動するために使用されます。",
|
||||
"active": "アクティブ",
|
||||
"active_surveys": "アクティブなフォーム",
|
||||
"activity": "アクティビティ",
|
||||
"add": "追加",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "アプリ",
|
||||
"app_survey": "アプリ内フォーム",
|
||||
"apply_filters": "フィルターを適用",
|
||||
"archived": "アーカイブ済み",
|
||||
"are_you_sure": "よろしいですか?",
|
||||
"attributes": "属性",
|
||||
"back": "戻る",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "チーム&アクセスロール(読み取り、読み書き、管理)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricksの全機能をアンロック。30日間無料。"
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "アーカイブ",
|
||||
"archive_directory": "ディレクトリをアーカイブ",
|
||||
"archive_not_allowed": "このディレクトリをアーカイブする権限がありません。",
|
||||
"are_you_sure_you_want_to_archive": "このディレクトリをアーカイブしてもよろしいですか?ワークスペースはアクセスできなくなります。",
|
||||
"assign_workspaces_description": "このフィードバック記録ディレクトリにアクセスできるワークスペースを管理します。",
|
||||
"create_feedback_directory": "フィードバックディレクトリを作成",
|
||||
"description": "フィードバック記録ディレクトリとワークスペースの割り当てを管理します。",
|
||||
"directory_archived_successfully": "ディレクトリをアーカイブしました",
|
||||
"directory_created_successfully": "ディレクトリを作成しました",
|
||||
"directory_id": "ディレクトリID",
|
||||
"directory_name": "ディレクトリ名",
|
||||
"directory_settings_description": "ディレクトリ名、ワークスペースの割り当てなどを管理します。",
|
||||
"directory_settings_title": "{directoryName}の設定",
|
||||
"directory_unarchived_successfully": "ディレクトリのアーカイブを解除しました",
|
||||
"directory_updated_successfully": "ディレクトリを更新しました",
|
||||
"empty_state": "フィードバック記録ディレクトリが見つかりません。最初のディレクトリを作成してください。",
|
||||
"enter_directory_name": "ディレクトリ名を入力してください",
|
||||
"error_directory_name_duplicate": "この名前のフィードバック記録ディレクトリは既に存在します。",
|
||||
"error_directory_name_required": "ディレクトリ名は必須です。",
|
||||
"error_directory_projects_invalid_org": "指定されたワークスペースの一部がこの組織に属していません。",
|
||||
"nav_label": "フィードバックディレクトリ",
|
||||
"no_access": "フィードバック記録ディレクトリを管理する権限がありません。",
|
||||
"select_workspaces_placeholder": "ワークスペースを選択...",
|
||||
"show_archived": "アーカイブ済みを表示",
|
||||
"title": "フィードバック記録ディレクトリ",
|
||||
"unarchive": "アーカイブ解除"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "無料プランでは、すべての組織メンバーに常に「オーナー」ロールが割り当てられます。",
|
||||
"cannot_delete_only_organization": "これはあなたの唯一の組織です。削除できません。まず新しい組織を作成してください。",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "Actie",
|
||||
"actions": "Acties",
|
||||
"actions_description": "Code- en no-code-acties worden gebruikt om onderscheppingsenquêtes in apps en op websites te activeren.",
|
||||
"active": "Actief",
|
||||
"active_surveys": "Actieve enquêtes",
|
||||
"activity": "Activiteit",
|
||||
"add": "Toevoegen",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "App",
|
||||
"app_survey": "App-enquête",
|
||||
"apply_filters": "Pas filters toe",
|
||||
"archived": "Gearchiveerd",
|
||||
"are_you_sure": "Weet je het zeker?",
|
||||
"attributes": "Kenmerken",
|
||||
"back": "Rug",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "Teams en toegangsrollen (lezen, lezen en schrijven, beheren)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Ontgrendel de volledige kracht van Formbricks. 30 dagen gratis."
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "Archiveren",
|
||||
"archive_directory": "Map archiveren",
|
||||
"archive_not_allowed": "Je hebt geen toestemming om deze map te archiveren.",
|
||||
"are_you_sure_you_want_to_archive": "Weet je zeker dat je deze map wilt archiveren? Workspaces hebben er dan geen toegang meer toe.",
|
||||
"assign_workspaces_description": "Bepaal welke workspaces toegang hebben tot deze feedbackregistratiemap.",
|
||||
"create_feedback_directory": "Feedbackmap maken",
|
||||
"description": "Beheer feedbackregistratiemappen en hun workspace-toewijzingen.",
|
||||
"directory_archived_successfully": "Map succesvol gearchiveerd",
|
||||
"directory_created_successfully": "Map succesvol aangemaakt",
|
||||
"directory_id": "Map-ID",
|
||||
"directory_name": "Mapnaam",
|
||||
"directory_settings_description": "Beheer mapnaam, workspace-toewijzingen en meer.",
|
||||
"directory_settings_title": "Instellingen voor {directoryName}",
|
||||
"directory_unarchived_successfully": "Map succesvol gedearchiveerd",
|
||||
"directory_updated_successfully": "Map succesvol bijgewerkt",
|
||||
"empty_state": "Geen feedbackregistratiemappen gevonden. Maak er een aan om te beginnen.",
|
||||
"enter_directory_name": "Voer mapnaam in",
|
||||
"error_directory_name_duplicate": "Er bestaat al een feedback-recordmap met deze naam.",
|
||||
"error_directory_name_required": "Mapnaam is verplicht.",
|
||||
"error_directory_projects_invalid_org": "Sommige opgegeven werkruimtes behoren niet tot deze organisatie.",
|
||||
"nav_label": "Feedbackmappen",
|
||||
"no_access": "Je hebt geen toestemming om feedbackregistratiemappen te beheren.",
|
||||
"select_workspaces_placeholder": "Selecteer werkruimtes...",
|
||||
"show_archived": "Gearchiveerde weergeven",
|
||||
"title": "Feedbackregistratiemappen",
|
||||
"unarchive": "Dearchiveren"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "Bij het gratis abonnement krijgen alle organisatieleden altijd de rol 'Eigenaar' toegewezen.",
|
||||
"cannot_delete_only_organization": "Dit is uw enige organisatie. Deze kan niet worden verwijderd. Maak eerst een nieuwe organisatie aan.",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "Ação",
|
||||
"actions": "Ações",
|
||||
"actions_description": "Ações de Código e Sem Código são usadas para acionar interceptar pesquisas dentro de apps & em sites.",
|
||||
"active": "Ativo",
|
||||
"active_surveys": "Pesquisas ativas",
|
||||
"activity": "Atividade",
|
||||
"add": "Adicionar",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "app",
|
||||
"app_survey": "Pesquisa de App",
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"archived": "Arquivado",
|
||||
"are_you_sure": "Certeza?",
|
||||
"attributes": "atributos",
|
||||
"back": "Voltar",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "Equipes e Funções de Acesso (Ler, Ler e Escrever, Gerenciar)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "Arquivar",
|
||||
"archive_directory": "Arquivar Diretório",
|
||||
"archive_not_allowed": "Você não tem permissão para arquivar este diretório.",
|
||||
"are_you_sure_you_want_to_archive": "Tem certeza de que deseja arquivar este diretório? Os espaços de trabalho não terão mais acesso a ele.",
|
||||
"assign_workspaces_description": "Controle quais espaços de trabalho podem acessar este diretório de registros de feedback.",
|
||||
"create_feedback_directory": "Criar diretório de feedback",
|
||||
"description": "Gerencie diretórios de registros de feedback e suas atribuições de espaços de trabalho.",
|
||||
"directory_archived_successfully": "Diretório arquivado com sucesso",
|
||||
"directory_created_successfully": "Diretório criado com sucesso",
|
||||
"directory_id": "ID do Diretório",
|
||||
"directory_name": "Nome do Diretório",
|
||||
"directory_settings_description": "Gerencie o nome do diretório, atribuições de espaços de trabalho e muito mais.",
|
||||
"directory_settings_title": "Configurações de {directoryName}",
|
||||
"directory_unarchived_successfully": "Diretório desarquivado com sucesso",
|
||||
"directory_updated_successfully": "Diretório atualizado com sucesso",
|
||||
"empty_state": "Nenhum diretório de registros de feedback encontrado. Crie um para começar.",
|
||||
"enter_directory_name": "Digite o nome do diretório",
|
||||
"error_directory_name_duplicate": "Já existe um diretório de registros de feedback com este nome.",
|
||||
"error_directory_name_required": "O nome do diretório é obrigatório.",
|
||||
"error_directory_projects_invalid_org": "Alguns espaços de trabalho especificados não pertencem a esta organização.",
|
||||
"nav_label": "Diretórios de Feedback",
|
||||
"no_access": "Você não tem permissão para gerenciar diretórios de registros de feedback.",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
"show_archived": "Mostrar arquivados",
|
||||
"title": "Diretórios de Registros de Feedback",
|
||||
"unarchive": "Desarquivar"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "Por favor, note que no Plano Gratuito, todos os membros da organização são automaticamente atribuídos ao papel de 'Owner', independentemente do papel especificado no arquivo CSV.",
|
||||
"cannot_delete_only_organization": "Essa é sua única organização, não pode ser deletada. Crie uma nova organização primeiro.",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "Ação",
|
||||
"actions": "Ações",
|
||||
"actions_description": "As ações com código e sem código são usadas para acionar pesquisas de interceptação em apps e em sites.",
|
||||
"active": "Ativo",
|
||||
"active_surveys": "Inquéritos ativos",
|
||||
"activity": "Atividade",
|
||||
"add": "Adicionar",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "Aplicação",
|
||||
"app_survey": "Inquérito (app)",
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"archived": "Arquivado",
|
||||
"are_you_sure": "Tem a certeza?",
|
||||
"attributes": "Atributos",
|
||||
"back": "Voltar",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "Equipas e Funções de Acesso (Ler, Ler e Escrever, Gerir)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "Arquivar",
|
||||
"archive_directory": "Arquivar Diretório",
|
||||
"archive_not_allowed": "Não tens permissão para arquivar este diretório.",
|
||||
"are_you_sure_you_want_to_archive": "Tens a certeza de que queres arquivar este diretório? Os espaços de trabalho deixarão de ter acesso ao mesmo.",
|
||||
"assign_workspaces_description": "Controla quais os espaços de trabalho que podem aceder a este diretório de registos de feedback.",
|
||||
"create_feedback_directory": "Criar diretório de feedback",
|
||||
"description": "Gere diretórios de registos de feedback e as suas atribuições de espaços de trabalho.",
|
||||
"directory_archived_successfully": "Diretório arquivado com sucesso",
|
||||
"directory_created_successfully": "Diretório criado com sucesso",
|
||||
"directory_id": "ID do Diretório",
|
||||
"directory_name": "Nome do Diretório",
|
||||
"directory_settings_description": "Gere o nome do diretório, atribuições de espaços de trabalho e muito mais.",
|
||||
"directory_settings_title": "Definições de {directoryName}",
|
||||
"directory_unarchived_successfully": "Diretório desarquivado com sucesso",
|
||||
"directory_updated_successfully": "Diretório atualizado com sucesso",
|
||||
"empty_state": "Não foram encontrados diretórios de registos de feedback. Cria um para começar.",
|
||||
"enter_directory_name": "Insere o nome do diretório",
|
||||
"error_directory_name_duplicate": "Já existe um diretório de registos de feedback com este nome.",
|
||||
"error_directory_name_required": "O nome do diretório é obrigatório.",
|
||||
"error_directory_projects_invalid_org": "Algumas áreas de trabalho especificadas não pertencem a esta organização.",
|
||||
"nav_label": "Diretórios de Feedback",
|
||||
"no_access": "Não tens permissão para gerir diretórios de registos de feedback.",
|
||||
"select_workspaces_placeholder": "Selecionar espaços de trabalho...",
|
||||
"show_archived": "Mostrar arquivados",
|
||||
"title": "Diretórios de Registos de Feedback",
|
||||
"unarchive": "Desarquivar"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "No plano gratuito, todos os membros da organização são sempre atribuídos ao papel de \"Proprietário\".",
|
||||
"cannot_delete_only_organization": "Esta é a sua única organização, não pode ser eliminada. Crie uma nova organização primeiro.",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "Acțiune",
|
||||
"actions": "Acțiuni",
|
||||
"actions_description": "Acțiunile Cod și No-Code sunt utilizate pentru a declanșa chestionare de interceptare în aplicații și pe site-uri web.",
|
||||
"active": "Activ",
|
||||
"active_surveys": "Sondaje active",
|
||||
"activity": "Activitate",
|
||||
"add": "Adaugă",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "Aplicație",
|
||||
"app_survey": "Sondaj aplicație",
|
||||
"apply_filters": "Aplică filtre",
|
||||
"archived": "Arhivat",
|
||||
"are_you_sure": "Ești sigur?",
|
||||
"attributes": "Atribute",
|
||||
"back": "Înapoi",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "Echipe & Roluri de Acces (Citiți, Citiți și Scrieți, Gestionați)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Deblocați puterea completă a Formbricks. Gratuit timp de 30 de zile."
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "Arhivează",
|
||||
"archive_directory": "Arhivează directorul",
|
||||
"archive_not_allowed": "Nu ai permisiunea să arhivezi acest director.",
|
||||
"are_you_sure_you_want_to_archive": "Ești sigur că vrei să arhivezi acest director? Spațiile de lucru nu vor mai avea acces la el.",
|
||||
"assign_workspaces_description": "Controlează care spații de lucru pot accesa acest director de înregistrări de feedback.",
|
||||
"create_feedback_directory": "Creează director de feedback",
|
||||
"description": "Gestionează directoarele de înregistrări de feedback și atribuirile lor la spații de lucru.",
|
||||
"directory_archived_successfully": "Directorul a fost arhivat cu succes",
|
||||
"directory_created_successfully": "Directorul a fost creat cu succes",
|
||||
"directory_id": "ID director",
|
||||
"directory_name": "Numele directorului",
|
||||
"directory_settings_description": "Gestionează numele directorului, atribuirile la spații de lucru și multe altele.",
|
||||
"directory_settings_title": "Setări {directoryName}",
|
||||
"directory_unarchived_successfully": "Directorul a fost dezarhivat cu succes",
|
||||
"directory_updated_successfully": "Directorul a fost actualizat cu succes",
|
||||
"empty_state": "Nu au fost găsite directoare de înregistrări de feedback. Creează unul pentru a începe.",
|
||||
"enter_directory_name": "Introdu numele directorului",
|
||||
"error_directory_name_duplicate": "Există deja un director de înregistrări feedback cu acest nume.",
|
||||
"error_directory_name_required": "Numele directorului este obligatoriu.",
|
||||
"error_directory_projects_invalid_org": "Unele spații de lucru specificate nu aparțin acestei organizații.",
|
||||
"nav_label": "Directoare de feedback",
|
||||
"no_access": "Nu ai permisiunea de a gestiona directoarele de înregistrări de feedback.",
|
||||
"select_workspaces_placeholder": "Selectează spații de lucru...",
|
||||
"show_archived": "Afișează arhivate",
|
||||
"title": "Directoare de Înregistrări Feedback",
|
||||
"unarchive": "Dezarhivează"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "În planul gratuit, toți membrii organizației sunt întotdeauna alocați rolului „Proprietar”.",
|
||||
"cannot_delete_only_organization": "Aceasta este singura ta organizație, nu poate fi ștearsă. Creează mai întâi o nouă organizație.",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "Действие",
|
||||
"actions": "Действия",
|
||||
"actions_description": "Действия с кодом и без кода используются для запуска опросов-перехватчиков в приложениях и на сайтах.",
|
||||
"active": "Активный",
|
||||
"active_surveys": "Активные опросы",
|
||||
"activity": "Активность",
|
||||
"add": "Добавить",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "Приложение",
|
||||
"app_survey": "Опрос о приложении",
|
||||
"apply_filters": "Применить фильтры",
|
||||
"archived": "Архивный",
|
||||
"are_you_sure": "Вы уверены?",
|
||||
"attributes": "Атрибуты",
|
||||
"back": "Назад",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "Команды и роли доступа (чтение, чтение и запись, управление)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Откройте все возможности Formbricks. Бесплатно на 30 дней."
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "Архивировать",
|
||||
"archive_directory": "Архивировать каталог",
|
||||
"archive_not_allowed": "У тебя нет прав для архивирования этого каталога.",
|
||||
"are_you_sure_you_want_to_archive": "Ты уверен, что хочешь архивировать этот каталог? Рабочие пространства больше не будут иметь к нему доступа.",
|
||||
"assign_workspaces_description": "Управляй тем, какие рабочие пространства могут получить доступ к этому каталогу записей отзывов.",
|
||||
"create_feedback_directory": "Создать директорию для отзывов",
|
||||
"description": "Управляй каталогами записей отзывов и их назначением рабочим пространствам.",
|
||||
"directory_archived_successfully": "Каталог успешно архивирован",
|
||||
"directory_created_successfully": "Каталог успешно создан",
|
||||
"directory_id": "ID каталога",
|
||||
"directory_name": "Название каталога",
|
||||
"directory_settings_description": "Управляй названием каталога, назначением рабочих пространств и другими параметрами.",
|
||||
"directory_settings_title": "Настройки {directoryName}",
|
||||
"directory_unarchived_successfully": "Каталог успешно разархивирован",
|
||||
"directory_updated_successfully": "Каталог успешно обновлён",
|
||||
"empty_state": "Каталоги записей отзывов не найдены. Создай один, чтобы начать.",
|
||||
"enter_directory_name": "Введи название каталога",
|
||||
"error_directory_name_duplicate": "Директория с записями обратной связи с таким именем уже существует.",
|
||||
"error_directory_name_required": "Необходимо указать имя директории.",
|
||||
"error_directory_projects_invalid_org": "Некоторые указанные рабочие пространства не принадлежат этой организации.",
|
||||
"nav_label": "Каталоги отзывов",
|
||||
"no_access": "У тебя нет прав для управления каталогами записей отзывов.",
|
||||
"select_workspaces_placeholder": "Выберите рабочие области...",
|
||||
"show_archived": "Показать архивные",
|
||||
"title": "Директории записей обратной связи",
|
||||
"unarchive": "Разархивировать"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "В бесплатном тарифе всем участникам организации всегда назначается роль \"Владелец\".",
|
||||
"cannot_delete_only_organization": "Это ваша единственная организация, её нельзя удалить. Сначала создайте новую организацию.",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "Åtgärd",
|
||||
"actions": "Åtgärder",
|
||||
"actions_description": "Kod- och No-Code-åtgärder används för att utlösa enkäter i appar och på webbplatser.",
|
||||
"active": "Aktiv",
|
||||
"active_surveys": "Aktiva enkäter",
|
||||
"activity": "Aktivitet",
|
||||
"add": "Lägg till",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "App",
|
||||
"app_survey": "App-enkät",
|
||||
"apply_filters": "Tillämpa filter",
|
||||
"archived": "Arkiverad",
|
||||
"are_you_sure": "Är du säker?",
|
||||
"attributes": "Attribut",
|
||||
"back": "Tillbaka",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "Team och åtkomstroller (Läs, Läs och skriv, Hantera)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "Lås upp Formbricks fulla kraft. Gratis i 30 dagar."
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "Arkivera",
|
||||
"archive_directory": "Arkivera katalog",
|
||||
"archive_not_allowed": "Du har inte behörighet att arkivera den här katalogen.",
|
||||
"are_you_sure_you_want_to_archive": "Är du säker på att du vill arkivera den här katalogen? Arbetsytor kommer inte längre ha tillgång till den.",
|
||||
"assign_workspaces_description": "Styr vilka arbetsytor som kan komma åt den här katalogen för feedbackposter.",
|
||||
"create_feedback_directory": "Skapa feedbackkatalog",
|
||||
"description": "Hantera kataloger för feedbackposter och deras arbetsytstilldelningar.",
|
||||
"directory_archived_successfully": "Katalogen arkiverades",
|
||||
"directory_created_successfully": "Katalogen skapades",
|
||||
"directory_id": "Katalog-ID",
|
||||
"directory_name": "Katalognamn",
|
||||
"directory_settings_description": "Hantera katalognamn, arbetsytstilldelningar och mer.",
|
||||
"directory_settings_title": "Inställningar för {directoryName}",
|
||||
"directory_unarchived_successfully": "Katalogen återställdes från arkivet",
|
||||
"directory_updated_successfully": "Katalogen uppdaterades",
|
||||
"empty_state": "Inga kataloger för feedbackposter hittades. Skapa en för att komma igång.",
|
||||
"enter_directory_name": "Ange katalognamn",
|
||||
"error_directory_name_duplicate": "En katalog för återkopplingsregister med detta namn finns redan.",
|
||||
"error_directory_name_required": "Katalognamn krävs.",
|
||||
"error_directory_projects_invalid_org": "Vissa angivna arbetsytor tillhör inte denna organisation.",
|
||||
"nav_label": "Feedbackkataloger",
|
||||
"no_access": "Du har inte behörighet att hantera kataloger för feedbackposter.",
|
||||
"select_workspaces_placeholder": "Välj arbetsytor...",
|
||||
"show_archived": "Visa arkiverade",
|
||||
"title": "Feedbackkataloger",
|
||||
"unarchive": "Avarkivera"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "På gratisplanen tilldelas alla organisationsmedlemmar alltid rollen \"Ägare\".",
|
||||
"cannot_delete_only_organization": "Detta är din enda organisation, den kan inte tas bort. Skapa en ny organisation först.",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "操作",
|
||||
"actions": "操作",
|
||||
"actions_description": "代码 和 无代码 操作 用于 触发 拦截 调查 在 应用程序 和 网站 中。",
|
||||
"active": "活跃",
|
||||
"active_surveys": "活跃 调查",
|
||||
"activity": "活动",
|
||||
"add": "添加",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "应用",
|
||||
"app_survey": "应用 程序 调查",
|
||||
"apply_filters": "应用 筛选",
|
||||
"archived": "已归档",
|
||||
"are_you_sure": "你 确定 吗?",
|
||||
"attributes": "属性",
|
||||
"back": "返回",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "团队 & 访问 角色(读取, 读取 & 写入, 管理)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "解锁 Formbricks 的全部功能。免费使用 30 天。"
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "归档",
|
||||
"archive_directory": "归档目录",
|
||||
"archive_not_allowed": "你无权归档此目录。",
|
||||
"are_you_sure_you_want_to_archive": "确定要归档此目录吗?工作区将无法再访问它。",
|
||||
"assign_workspaces_description": "控制哪些工作区可以访问此反馈记录目录。",
|
||||
"create_feedback_directory": "创建反馈目录",
|
||||
"description": "管理反馈记录目录及其工作区分配。",
|
||||
"directory_archived_successfully": "目录已成功归档",
|
||||
"directory_created_successfully": "目录已成功创建",
|
||||
"directory_id": "目录 ID",
|
||||
"directory_name": "目录名称",
|
||||
"directory_settings_description": "管理目录名称、工作区分配等。",
|
||||
"directory_settings_title": "{directoryName} 设置",
|
||||
"directory_unarchived_successfully": "目录已成功取消归档",
|
||||
"directory_updated_successfully": "目录已成功更新",
|
||||
"empty_state": "未找到反馈记录目录。创建一个开始使用吧。",
|
||||
"enter_directory_name": "输入目录名称",
|
||||
"error_directory_name_duplicate": "已存在同名的反馈记录目录。",
|
||||
"error_directory_name_required": "目录名称为必填项。",
|
||||
"error_directory_projects_invalid_org": "某些指定的工作区不属于此组织。",
|
||||
"nav_label": "反馈目录",
|
||||
"no_access": "你没有管理反馈记录目录的权限。",
|
||||
"select_workspaces_placeholder": "选择工作区...",
|
||||
"show_archived": "显示已归档",
|
||||
"title": "反馈记录目录",
|
||||
"unarchive": "取消归档"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "在免费计划中,所有组织成员都会被分配为 \"Owner \"角色。",
|
||||
"cannot_delete_only_organization": "这是 您 唯一的 组织,不可 删除。请 先 创建一个新的 组织。",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"action": "操作",
|
||||
"actions": "操作",
|
||||
"actions_description": "代碼 和 無代碼 動作 用於 觸發 截取 調查 於 應用程式 和 網站上 。",
|
||||
"active": "啟用中",
|
||||
"active_surveys": "啟用中的問卷",
|
||||
"activity": "活動",
|
||||
"add": "新增",
|
||||
@@ -139,6 +140,7 @@
|
||||
"app": "應用程式",
|
||||
"app_survey": "應用程式問卷",
|
||||
"apply_filters": "套用篩選器",
|
||||
"archived": "已封存",
|
||||
"are_you_sure": "您確定嗎?",
|
||||
"attributes": "屬性",
|
||||
"back": "返回",
|
||||
@@ -1133,6 +1135,34 @@
|
||||
"teams": "團隊和存取角色(讀取、讀取和寫入、管理)",
|
||||
"unlock_the_full_power_of_formbricks_free_for_30_days": "免費解鎖 Formbricks 的全部功能,為期 30 天。"
|
||||
},
|
||||
"feedback_record_directories": {
|
||||
"archive": "封存",
|
||||
"archive_directory": "封存目錄",
|
||||
"archive_not_allowed": "您沒有權限封存此目錄。",
|
||||
"are_you_sure_you_want_to_archive": "確定要封存此目錄嗎?工作區將無法再存取它。",
|
||||
"assign_workspaces_description": "控制哪些工作區可以存取此意見回饋記錄目錄。",
|
||||
"create_feedback_directory": "建立意見回饋目錄",
|
||||
"description": "管理意見回饋記錄目錄及其工作區配置。",
|
||||
"directory_archived_successfully": "目錄已成功封存",
|
||||
"directory_created_successfully": "目錄已成功建立",
|
||||
"directory_id": "目錄 ID",
|
||||
"directory_name": "目錄名稱",
|
||||
"directory_settings_description": "管理目錄名稱、工作區配置等設定。",
|
||||
"directory_settings_title": "{directoryName} 設定",
|
||||
"directory_unarchived_successfully": "目錄已成功取消封存",
|
||||
"directory_updated_successfully": "目錄已成功更新",
|
||||
"empty_state": "找不到任何意見回饋記錄目錄。建立一個開始使用吧。",
|
||||
"enter_directory_name": "輸入目錄名稱",
|
||||
"error_directory_name_duplicate": "已存在同名的意見回饋記錄目錄。",
|
||||
"error_directory_name_required": "目錄名稱為必填項目。",
|
||||
"error_directory_projects_invalid_org": "部分指定的工作區不屬於此組織。",
|
||||
"nav_label": "意見回饋目錄",
|
||||
"no_access": "您沒有權限管理意見回饋記錄目錄。",
|
||||
"select_workspaces_placeholder": "選擇工作區...",
|
||||
"show_archived": "顯示已封存",
|
||||
"title": "意見回饋記錄目錄",
|
||||
"unarchive": "取消封存"
|
||||
},
|
||||
"general": {
|
||||
"bulk_invite_warning_description": "在免費方案中,所有組織成員始終會被指派「擁有者」角色。",
|
||||
"cannot_delete_only_organization": "這是您唯一的組織,無法刪除。請先建立新組織。",
|
||||
|
||||
@@ -290,6 +290,9 @@ export const withAuditLogging = <
|
||||
case "quota":
|
||||
targetId = auditLoggingCtx.quotaId;
|
||||
break;
|
||||
case "feedbackRecordDirectory":
|
||||
targetId = auditLoggingCtx.feedbackRecordDirectoryId;
|
||||
break;
|
||||
default:
|
||||
targetId = UNKNOWN_DATA;
|
||||
break;
|
||||
|
||||
@@ -25,6 +25,7 @@ export const ZAuditTarget = z.enum([
|
||||
"integration",
|
||||
"file",
|
||||
"quota",
|
||||
"feedbackRecordDirectory",
|
||||
]);
|
||||
export const ZAuditAction = z.enum([
|
||||
"created",
|
||||
|
||||
103
apps/web/modules/ee/feedback-record-directory/actions.ts
Normal file
103
apps/web/modules/ee/feedback-record-directory/actions.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import {
|
||||
createFeedbackRecordDirectory,
|
||||
getFeedbackRecordDirectoryDetails,
|
||||
getOrganizationIdFromDirectoryId,
|
||||
updateFeedbackRecordDirectory,
|
||||
} from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { ZFeedbackRecordDirectoryUpdateInput } from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
|
||||
const ZCreateFeedbackRecordDirectoryAction = z.object({
|
||||
organizationId: ZId,
|
||||
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED"),
|
||||
});
|
||||
|
||||
export const createFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateFeedbackRecordDirectoryAction)
|
||||
.action(
|
||||
withAuditLogging("created", "feedbackRecordDirectory", async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await createFeedbackRecordDirectory(parsedInput.organizationId, parsedInput.name);
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.feedbackRecordDirectoryId = result;
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
...(await getFeedbackRecordDirectoryDetails(result)),
|
||||
};
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const ZGetFeedbackRecordDirectoryDetailsAction = z.object({
|
||||
directoryId: ZId,
|
||||
});
|
||||
|
||||
export const getFeedbackRecordDirectoryDetailsAction = authenticatedActionClient
|
||||
.inputSchema(ZGetFeedbackRecordDirectoryDetailsAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const organizationId = await getOrganizationIdFromDirectoryId(parsedInput.directoryId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
|
||||
});
|
||||
|
||||
const ZUpdateFeedbackRecordDirectoryAction = z.object({
|
||||
directoryId: ZId,
|
||||
data: ZFeedbackRecordDirectoryUpdateInput,
|
||||
});
|
||||
|
||||
export const updateFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateFeedbackRecordDirectoryAction)
|
||||
.action(
|
||||
withAuditLogging("updated", "feedbackRecordDirectory", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromDirectoryId(parsedInput.directoryId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.feedbackRecordDirectoryId = parsedInput.directoryId;
|
||||
const oldObject = await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
|
||||
const result = await updateFeedbackRecordDirectory(
|
||||
parsedInput.directoryId,
|
||||
organizationId,
|
||||
parsedInput.data
|
||||
);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
|
||||
return result;
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createFeedbackRecordDirectoryAction } from "@/modules/ee/feedback-record-directory/actions";
|
||||
import {
|
||||
TFeedbackRecordDirectoryCreateInput,
|
||||
ZFeedbackRecordDirectoryCreateInput,
|
||||
getTranslatedFeedbackRecordDirectoryError,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface CreateFeedbackRecordDirectoryModalProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export const CreateFeedbackRecordDirectoryModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
organizationId,
|
||||
}: CreateFeedbackRecordDirectoryModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TFeedbackRecordDirectoryCreateInput>({
|
||||
defaultValues: { name: "" },
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZFeedbackRecordDirectoryCreateInput),
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = form;
|
||||
|
||||
const handleCreation: SubmitHandler<TFeedbackRecordDirectoryCreateInput> = async (data) => {
|
||||
const response = await createFeedbackRecordDirectoryAction({ name: data.name, organizationId });
|
||||
if (response?.data) {
|
||||
toast.success(t("environments.settings.feedback_record_directories.directory_created_successfully"));
|
||||
router.refresh();
|
||||
setOpen(false);
|
||||
reset();
|
||||
} else {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("environments.settings.feedback_record_directories.create_feedback_directory")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(handleCreation)} className="gap-y-4 pt-4">
|
||||
<DialogBody>
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="pb-4">
|
||||
<FormLabel>
|
||||
{t("environments.settings.feedback_record_directories.directory_name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"environments.settings.feedback_record_directories.enter_directory_name"
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={!form.formState.isValid || isSubmitting} loading={isSubmitting} type="submit">
|
||||
{t("environments.settings.feedback_record_directories.create_feedback_directory")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateFeedbackRecordDirectoryAction } from "@/modules/ee/feedback-record-directory/actions";
|
||||
import { getTranslatedFeedbackRecordDirectoryError } from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ArchiveFeedbackRecordDirectoryProps {
|
||||
directoryId: string;
|
||||
onArchive: () => void;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
export const ArchiveFeedbackRecordDirectory = ({
|
||||
directoryId,
|
||||
onArchive,
|
||||
isOwnerOrManager,
|
||||
}: ArchiveFeedbackRecordDirectoryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isArchiveDialogOpen, setIsArchiveDialogOpen] = useState(false);
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleArchive = async () => {
|
||||
setIsArchiving(true);
|
||||
|
||||
const response = await updateFeedbackRecordDirectoryAction({ directoryId, data: { isArchived: true } });
|
||||
if (response?.serverError) {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
setIsArchiveDialogOpen(false);
|
||||
setIsArchiving(false);
|
||||
return;
|
||||
}
|
||||
if (response?.data) {
|
||||
toast.success(t("environments.settings.feedback_record_directories.directory_archived_successfully"));
|
||||
onArchive?.();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
|
||||
setIsArchiveDialogOpen(false);
|
||||
setIsArchiving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-baseline space-x-2">
|
||||
<TooltipRenderer
|
||||
shouldRender={!isOwnerOrManager}
|
||||
tooltipContent={t("environments.settings.feedback_record_directories.archive_not_allowed")}
|
||||
className="w-auto">
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
className="w-auto"
|
||||
disabled={!isOwnerOrManager}
|
||||
onClick={() => setIsArchiveDialogOpen(true)}>
|
||||
{t("environments.settings.feedback_record_directories.archive_directory")}
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
|
||||
{isArchiveDialogOpen && (
|
||||
<Dialog open={isArchiveDialogOpen} onOpenChange={setIsArchiveDialogOpen}>
|
||||
<DialogContent width="narrow" hideCloseButton={true} disableCloseOnOutsideClick={true}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
<DialogTitle>
|
||||
{t("environments.settings.feedback_record_directories.archive_directory")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<p>{t("environments.settings.feedback_record_directories.are_you_sure_you_want_to_archive")}</p>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setIsArchiveDialogOpen(false)}
|
||||
disabled={isArchiving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleArchive} loading={isArchiving}>
|
||||
{t("environments.settings.feedback_record_directories.archive")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateFeedbackRecordDirectoryAction } from "@/modules/ee/feedback-record-directory/actions";
|
||||
import { ArchiveFeedbackRecordDirectory } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/archive-feedback-record-directory";
|
||||
import {
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TFeedbackRecordDirectoryUpdateInput,
|
||||
ZFeedbackRecordDirectoryUpdateInput,
|
||||
getTranslatedFeedbackRecordDirectoryError,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
import { Muted } from "@/modules/ui/components/typography";
|
||||
|
||||
interface FeedbackRecordDirectorySettingsModalProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
directory: TFeedbackRecordDirectoryDetails;
|
||||
orgProjects: TOrganizationProject[];
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
export const FeedbackRecordDirectorySettingsModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
directory,
|
||||
orgProjects,
|
||||
membershipRole,
|
||||
}: FeedbackRecordDirectorySettingsModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
const router = useRouter();
|
||||
|
||||
const projectOptions = useMemo(
|
||||
() =>
|
||||
orgProjects
|
||||
.map((p) => ({ value: p.id, label: p.name }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })),
|
||||
[orgProjects]
|
||||
);
|
||||
|
||||
const initialProjectIds = useMemo(() => directory.projects.map((p) => p.projectId), [directory.projects]);
|
||||
|
||||
const form = useForm<TFeedbackRecordDirectoryUpdateInput>({
|
||||
defaultValues: {
|
||||
name: directory.name,
|
||||
projectIds: initialProjectIds,
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZFeedbackRecordDirectoryUpdateInput),
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue,
|
||||
} = form;
|
||||
|
||||
const closeSettingsModal = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleUpdate: SubmitHandler<TFeedbackRecordDirectoryUpdateInput> = async (data) => {
|
||||
const response = await updateFeedbackRecordDirectoryAction({
|
||||
directoryId: directory.id,
|
||||
data: {
|
||||
name: data.name,
|
||||
projectIds: data.projectIds,
|
||||
},
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
toast.success(t("environments.settings.feedback_record_directories.directory_updated_successfully"));
|
||||
closeSettingsModal();
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="pb-4">
|
||||
<DialogTitle>
|
||||
{t("environments.settings.feedback_record_directories.directory_settings_title", {
|
||||
directoryName: directory.name,
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.settings.feedback_record_directories.directory_settings_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormProvider {...form}>
|
||||
<form className="contents space-y-4" onSubmit={handleSubmit(handleUpdate)}>
|
||||
<DialogBody className="flex-grow space-y-6 overflow-y-auto">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("environments.settings.feedback_record_directories.directory_name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("environments.settings.feedback_record_directories.directory_name")}
|
||||
{...field}
|
||||
disabled={!isOwnerOrManager}
|
||||
/>
|
||||
</FormControl>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<IdBadge
|
||||
id={directory.id}
|
||||
label={t("environments.settings.feedback_record_directories.directory_id")}
|
||||
variant="column"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("common.workspaces")}</FormLabel>
|
||||
<Muted className="block text-slate-500">
|
||||
{t("environments.settings.feedback_record_directories.assign_workspaces_description")}
|
||||
</Muted>
|
||||
<MultiSelect
|
||||
options={projectOptions}
|
||||
value={form.watch("projectIds")}
|
||||
onChange={(selected) => {
|
||||
setValue("projectIds", selected, { shouldDirty: true });
|
||||
}}
|
||||
disabled={!isOwnerOrManager}
|
||||
placeholder={t(
|
||||
"environments.settings.feedback_record_directories.select_workspaces_placeholder"
|
||||
)}
|
||||
containerClassName="focus-within:ring-0 focus-within:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<div className="w-full">
|
||||
<ArchiveFeedbackRecordDirectory
|
||||
directoryId={directory.id}
|
||||
onArchive={closeSettingsModal}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
</div>
|
||||
<Button size="default" type="button" variant="outline" onClick={closeSettingsModal}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="default" loading={isSubmitting} disabled={!isOwnerOrManager}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
getFeedbackRecordDirectoryDetailsAction,
|
||||
updateFeedbackRecordDirectoryAction,
|
||||
} from "@/modules/ee/feedback-record-directory/actions";
|
||||
import { CreateFeedbackRecordDirectoryModal } from "@/modules/ee/feedback-record-directory/components/create-feedback-record-directory-modal";
|
||||
import { FeedbackRecordDirectorySettingsModal } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-settings/feedback-record-directory-settings-modal";
|
||||
import {
|
||||
TFeedbackRecordDirectory,
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
getTranslatedFeedbackRecordDirectoryError,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
import { TOrganizationProject } from "@/modules/ee/teams/team-list/types/project";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
interface FeedbackRecordDirectoryTableProps {
|
||||
directories: TFeedbackRecordDirectory[];
|
||||
organizationId: string;
|
||||
orgProjects: TOrganizationProject[];
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
export const FeedbackRecordDirectoryTable = ({
|
||||
directories,
|
||||
organizationId,
|
||||
orgProjects,
|
||||
membershipRole,
|
||||
}: FeedbackRecordDirectoryTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
||||
const [selectedDirectory, setSelectedDirectory] = useState<TFeedbackRecordDirectoryDetails>();
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [loadingDirectoryId, setLoadingDirectoryId] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
const handleManageDirectory = async (directoryId: string) => {
|
||||
setLoadingDirectoryId(directoryId);
|
||||
try {
|
||||
const response = await getFeedbackRecordDirectoryDetailsAction({ directoryId });
|
||||
|
||||
if (response?.data) {
|
||||
setSelectedDirectory(response.data);
|
||||
setOpenSettingsModal(true);
|
||||
} else {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
}
|
||||
} finally {
|
||||
setLoadingDirectoryId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnarchiveDirectory = async (directoryId: string) => {
|
||||
setLoadingDirectoryId(directoryId);
|
||||
try {
|
||||
const response = await updateFeedbackRecordDirectoryAction({
|
||||
directoryId,
|
||||
data: { isArchived: false },
|
||||
});
|
||||
if (response?.data) {
|
||||
toast.success(
|
||||
t("environments.settings.feedback_record_directories.directory_unarchived_successfully")
|
||||
);
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
}
|
||||
} finally {
|
||||
setLoadingDirectoryId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredDirectories = showArchived ? directories : directories.filter((d) => !d.isArchived);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOwnerOrManager && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={showArchived} onCheckedChange={setShowArchived} />
|
||||
<span className="text-sm text-slate-500">
|
||||
{t("environments.settings.feedback_record_directories.show_archived")}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setOpenCreateModal(true)}>
|
||||
{t("environments.settings.feedback_record_directories.create_feedback_directory")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border" aria-label="Feedback record directories list">
|
||||
<Table>
|
||||
<TableHeader role="rowgroup">
|
||||
<TableRow className="bg-slate-100" role="row">
|
||||
<TableHead className="font-medium text-slate-500">
|
||||
{t("environments.settings.feedback_record_directories.directory_name")}
|
||||
</TableHead>
|
||||
<TableHead className="font-medium text-slate-500">{t("common.workspaces")}</TableHead>
|
||||
<TableHead className="font-medium text-slate-500">{t("common.status")}</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="[&_tr:last-child]:border-b">
|
||||
{filteredDirectories.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center hover:bg-transparent">
|
||||
{t("environments.settings.feedback_record_directories.empty_state")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredDirectories.map((directory) => (
|
||||
<TableRow key={directory.id} className="hover:bg-transparent">
|
||||
<TableCell>{directory.name}</TableCell>
|
||||
<TableCell>{directory.projectCount}</TableCell>
|
||||
<TableCell>
|
||||
{directory.isArchived ? (
|
||||
<Badge type="gray" size="tiny" text={t("common.archived")} />
|
||||
) : (
|
||||
<Badge type="success" size="tiny" text={t("common.active")} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="flex justify-end gap-2">
|
||||
{isOwnerOrManager && !directory.isArchived && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={loadingDirectoryId === directory.id}
|
||||
disabled={loadingDirectoryId !== null}
|
||||
onClick={() => handleManageDirectory(directory.id)}>
|
||||
{t("common.manage")}
|
||||
</Button>
|
||||
)}
|
||||
{isOwnerOrManager && directory.isArchived && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={loadingDirectoryId === directory.id}
|
||||
disabled={loadingDirectoryId !== null}
|
||||
onClick={() => handleUnarchiveDirectory(directory.id)}>
|
||||
{t("environments.settings.feedback_record_directories.unarchive")}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<CreateFeedbackRecordDirectoryModal
|
||||
open={openCreateModal}
|
||||
setOpen={setOpenCreateModal}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
|
||||
{openSettingsModal && selectedDirectory && (
|
||||
<FeedbackRecordDirectorySettingsModal
|
||||
open={openSettingsModal}
|
||||
setOpen={setOpenSettingsModal}
|
||||
directory={selectedDirectory}
|
||||
orgProjects={orgProjects}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { FeedbackRecordDirectoryTable } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-table";
|
||||
import { getFeedbackRecordDirectories } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getProjectsByOrganizationId } from "@/modules/ee/teams/team-list/lib/project";
|
||||
|
||||
interface FeedbackRecordDirectoryViewProps {
|
||||
organizationId: string;
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
export const FeedbackRecordDirectoryView = async ({
|
||||
organizationId,
|
||||
membershipRole,
|
||||
}: FeedbackRecordDirectoryViewProps) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const [directories, orgProjects] = await Promise.all([
|
||||
getFeedbackRecordDirectories(organizationId),
|
||||
getProjectsByOrganizationId(organizationId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
title={t("environments.settings.feedback_record_directories.title")}
|
||||
description={t("environments.settings.feedback_record_directories.description")}>
|
||||
<FeedbackRecordDirectoryTable
|
||||
directories={directories}
|
||||
organizationId={organizationId}
|
||||
orgProjects={orgProjects}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,317 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
createFeedbackRecordDirectory,
|
||||
getFeedbackRecordDirectories,
|
||||
getFeedbackRecordDirectoryDetails,
|
||||
getOrganizationIdFromDirectoryId,
|
||||
updateFeedbackRecordDirectory,
|
||||
} from "./feedback-record-directory";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
feedbackRecordDirectory: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
project: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockDirectoryId = "clj28r6va000409j3ep7h8xzk";
|
||||
const mockOrganizationId = "clj28r6va000409j3ep7h8xyz";
|
||||
const mockProjectId1 = "clj28r6va000409j3ep7h8ab1";
|
||||
const mockProjectId2 = "clj28r6va000409j3ep7h8ab2";
|
||||
|
||||
const mockDirectoryDbRow = {
|
||||
id: mockDirectoryId,
|
||||
name: "Test Directory",
|
||||
isArchived: false,
|
||||
_count: { projects: 2 },
|
||||
};
|
||||
|
||||
const mockDirectoryDetailsDbRow = {
|
||||
id: mockDirectoryId,
|
||||
name: "Test Directory",
|
||||
isArchived: false,
|
||||
organizationId: mockOrganizationId,
|
||||
projects: [
|
||||
{ projectId: mockProjectId1, project: { name: "Project A" } },
|
||||
{ projectId: mockProjectId2, project: { name: "Project B" } },
|
||||
],
|
||||
};
|
||||
|
||||
describe("FeedbackRecordDirectory Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getFeedbackRecordDirectories", () => {
|
||||
test("returns directories with project counts", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockResolvedValueOnce([mockDirectoryDbRow] as any);
|
||||
|
||||
const result = await getFeedbackRecordDirectories(mockOrganizationId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockDirectoryId,
|
||||
name: "Test Directory",
|
||||
isArchived: false,
|
||||
projectCount: 2,
|
||||
},
|
||||
]);
|
||||
expect(prisma.feedbackRecordDirectory.findMany).toHaveBeenCalledWith({
|
||||
where: { organizationId: mockOrganizationId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isArchived: true,
|
||||
_count: { select: { projects: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no directories exist", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getFeedbackRecordDirectories(mockOrganizationId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(getFeedbackRecordDirectories(mockOrganizationId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("re-throws unexpected errors", async () => {
|
||||
const error = new Error("Unexpected error");
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findMany).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getFeedbackRecordDirectories(mockOrganizationId)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFeedbackRecordDirectoryDetails", () => {
|
||||
test("returns directory details with project assignments", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(
|
||||
mockDirectoryDetailsDbRow as any
|
||||
);
|
||||
|
||||
const result = await getFeedbackRecordDirectoryDetails(mockDirectoryId);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: mockDirectoryId,
|
||||
name: "Test Directory",
|
||||
isArchived: false,
|
||||
organizationId: mockOrganizationId,
|
||||
projects: [
|
||||
{ projectId: mockProjectId1, projectName: "Project A" },
|
||||
{ projectId: mockProjectId2, projectName: "Project B" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when directory not found", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getFeedbackRecordDirectoryDetails(mockDirectoryId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(getFeedbackRecordDirectoryDetails(mockDirectoryId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeedbackRecordDirectory", () => {
|
||||
test("creates a directory and returns its ID", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.create).mockResolvedValueOnce({
|
||||
id: mockDirectoryId,
|
||||
} as any);
|
||||
|
||||
const result = await createFeedbackRecordDirectory(mockOrganizationId, "New Directory");
|
||||
|
||||
expect(result).toBe(mockDirectoryId);
|
||||
expect(prisma.feedbackRecordDirectory.create).toHaveBeenCalledWith({
|
||||
data: { name: "New Directory", organizationId: mockOrganizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.create).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(createFeedbackRecordDirectory(mockOrganizationId, "Duplicate")).rejects.toThrow(
|
||||
new InvalidInputError("DIRECTORY_NAME_DUPLICATE")
|
||||
);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other Prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.create).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(createFeedbackRecordDirectory(mockOrganizationId, "Test")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("re-throws unexpected errors", async () => {
|
||||
const error = new Error("Unexpected");
|
||||
vi.mocked(prisma.feedbackRecordDirectory.create).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(createFeedbackRecordDirectory(mockOrganizationId, "Test")).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateFeedbackRecordDirectory", () => {
|
||||
test("updates directory name", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, {
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.feedbackRecordDirectory.update).toHaveBeenCalledWith({
|
||||
where: { id: mockDirectoryId },
|
||||
data: { name: "Updated Name" },
|
||||
});
|
||||
});
|
||||
|
||||
test("updates archive status", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, {
|
||||
isArchived: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.feedbackRecordDirectory.update).toHaveBeenCalledWith({
|
||||
where: { id: mockDirectoryId },
|
||||
data: { isArchived: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("updates project assignments with diff", async () => {
|
||||
// getFeedbackRecordDirectoryDetails call
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(
|
||||
mockDirectoryDetailsDbRow as any
|
||||
);
|
||||
|
||||
vi.mocked(prisma.project.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
// Keep project1, remove project2 (by not including it)
|
||||
const result = await updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, {
|
||||
projectIds: [mockProjectId1],
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.project.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: { in: [mockProjectId1] },
|
||||
organizationId: mockOrganizationId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when directory does not exist (P2025)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(
|
||||
updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, { name: "Test" })
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when projects belong to different org", async () => {
|
||||
// getFeedbackRecordDirectoryDetails call
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(
|
||||
mockDirectoryDetailsDbRow as any
|
||||
);
|
||||
|
||||
// count returns 0 — none of the projects belong to this org
|
||||
vi.mocked(prisma.project.count).mockResolvedValueOnce(0);
|
||||
|
||||
await expect(
|
||||
updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, {
|
||||
projectIds: [mockProjectId1],
|
||||
})
|
||||
).rejects.toThrow(new InvalidInputError("DIRECTORY_PROJECTS_INVALID_ORG"));
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(
|
||||
updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, { name: "Duplicate" })
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other Prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(
|
||||
updateFeedbackRecordDirectory(mockDirectoryId, mockOrganizationId, { name: "Test" })
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrganizationIdFromDirectoryId", () => {
|
||||
test("returns organization ID for a valid directory", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce({
|
||||
organizationId: mockOrganizationId,
|
||||
} as any);
|
||||
|
||||
const result = await getOrganizationIdFromDirectoryId(mockDirectoryId);
|
||||
|
||||
expect(result).toBe(mockOrganizationId);
|
||||
expect(prisma.feedbackRecordDirectory.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockDirectoryId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when directory does not exist", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getOrganizationIdFromDirectoryId(mockDirectoryId)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import "server-only";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
TFeedbackRecordDirectory,
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TFeedbackRecordDirectoryUpdateInput,
|
||||
ZFeedbackRecordDirectoryUpdateInput,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
|
||||
/**
|
||||
* Retrieves all feedback record directories for a given organization.
|
||||
*
|
||||
* @param organizationId - The ID of the organization to fetch directories for.
|
||||
* @returns An array of feedback record directories with their id, name, archive status, and assigned project count.
|
||||
* @throws {ValidationError} If the organizationId fails input validation.
|
||||
* @throws {DatabaseError} If a Prisma database error occurs.
|
||||
* @throws Re-throws any other unexpected errors.
|
||||
*/
|
||||
export const getFeedbackRecordDirectories = reactCache(
|
||||
async (organizationId: string): Promise<TFeedbackRecordDirectory[]> => {
|
||||
validateInputs([organizationId, ZId]);
|
||||
try {
|
||||
const directories = await prisma.feedbackRecordDirectory.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isArchived: true,
|
||||
_count: {
|
||||
select: {
|
||||
projects: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return directories.map((dir) => ({
|
||||
id: dir.id,
|
||||
name: dir.name,
|
||||
isArchived: dir.isArchived,
|
||||
projectCount: dir._count.projects,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieves the full details of a feedback record directory, including its assigned projects.
|
||||
*
|
||||
* @param directoryId - The ID of the directory to fetch.
|
||||
* @returns The directory details with project assignments, or `null` if not found.
|
||||
* @throws {ValidationError} If the directoryId fails input validation.
|
||||
* @throws {DatabaseError} If a Prisma database error occurs.
|
||||
* @throws Re-throws any other unexpected errors.
|
||||
*/
|
||||
export const getFeedbackRecordDirectoryDetails = reactCache(
|
||||
async (directoryId: string): Promise<TFeedbackRecordDirectoryDetails | null> => {
|
||||
validateInputs([directoryId, ZId]);
|
||||
try {
|
||||
const directory = await prisma.feedbackRecordDirectory.findUnique({
|
||||
where: {
|
||||
id: directoryId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isArchived: true,
|
||||
organizationId: true,
|
||||
projects: {
|
||||
select: {
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!directory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: directory.id,
|
||||
name: directory.name,
|
||||
isArchived: directory.isArchived,
|
||||
organizationId: directory.organizationId,
|
||||
projects: directory.projects.map((dp) => ({
|
||||
projectId: dp.projectId,
|
||||
projectName: dp.project.name,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates a new feedback record directory within an organization.
|
||||
*
|
||||
* @param organizationId - The ID of the organization to create the directory in.
|
||||
* @param name - The name for the new directory.
|
||||
* @returns The ID of the newly created directory.
|
||||
* @throws {ValidationError} If the inputs fail validation.
|
||||
* @throws {InvalidInputError} If a directory with the same name already exists in the organization,
|
||||
* or if the name is empty.
|
||||
* @throws {DatabaseError} If a Prisma database error occurs.
|
||||
* @throws Re-throws any other unexpected errors.
|
||||
*/
|
||||
export const createFeedbackRecordDirectory = async (
|
||||
organizationId: string,
|
||||
name: string
|
||||
): Promise<string> => {
|
||||
validateInputs([organizationId, ZId], [name, z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED")]);
|
||||
try {
|
||||
const directory = await prisma.feedbackRecordDirectory.create({
|
||||
data: {
|
||||
name,
|
||||
organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return directory.id;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("DIRECTORY_NAME_DUPLICATE");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the Prisma nested write payload for updating project assignments on a directory.
|
||||
* Validates that all specified projects belong to the directory's organization,
|
||||
* diffs against current assignments, and returns deleteMany + upsert operations.
|
||||
*
|
||||
* @param prismaClient - The Prisma client instance used for database queries.
|
||||
* @param directoryId - The ID of the directory being updated.
|
||||
* @param projectIds - The desired project IDs to assign.
|
||||
* @param organizationId - The organization the directory belongs to.
|
||||
* @param currentProjectIds - The currently assigned project IDs (avoids a redundant fetch).
|
||||
* @returns The Prisma nested write payload for the `projects` relation.
|
||||
* @throws {InvalidInputError} If any project does not belong to the organization.
|
||||
*/
|
||||
const buildProjectAssignmentPayload = async (
|
||||
prismaClient: PrismaClient,
|
||||
directoryId: string,
|
||||
projectIds: string[],
|
||||
organizationId: string,
|
||||
currentProjectIds: string[]
|
||||
): Promise<Prisma.FeedbackRecordDirectoryProjectUpdateManyWithoutFeedbackRecordDirectoryNestedInput> => {
|
||||
if (projectIds.length > 0) {
|
||||
const orgProjectsCount = await prismaClient.project.count({
|
||||
where: {
|
||||
id: { in: projectIds },
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
if (orgProjectsCount !== projectIds.length) {
|
||||
throw new InvalidInputError("DIRECTORY_PROJECTS_INVALID_ORG");
|
||||
}
|
||||
}
|
||||
|
||||
const deletedProjectIds = currentProjectIds.filter((id) => !projectIds.includes(id));
|
||||
|
||||
return {
|
||||
deleteMany: {
|
||||
projectId: { in: deletedProjectIds },
|
||||
},
|
||||
upsert: projectIds.map((projectId) => ({
|
||||
where: {
|
||||
feedbackRecordDirectoryId_projectId: {
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: { projectId },
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a feedback record directory. Supports partial updates for name, workspace
|
||||
* assignments, and archive status.
|
||||
*
|
||||
* When `projectIds` is provided, performs a diff against current assignments: removes
|
||||
* unassigned projects via `deleteMany` on the join table and upserts new/existing assignments.
|
||||
*
|
||||
* @param directoryId - The ID of the directory to update.
|
||||
* @param organizationId - The organization that owns the directory (avoids an extra fetch).
|
||||
* @param data - The partial update payload. All fields are optional.
|
||||
* @returns `true` on successful update.
|
||||
* @throws {ValidationError} If the inputs fail validation.
|
||||
* @throws {ResourceNotFoundError} If the directory does not exist (Prisma P2025).
|
||||
* @throws {InvalidInputError} If any specified project does not belong to the directory's organization,
|
||||
* or if the name conflicts with an existing directory in the same organization.
|
||||
* @throws {DatabaseError} If a Prisma database error occurs.
|
||||
* @throws Re-throws any other unexpected errors.
|
||||
*/
|
||||
export const updateFeedbackRecordDirectory = async (
|
||||
directoryId: string,
|
||||
organizationId: string,
|
||||
data: TFeedbackRecordDirectoryUpdateInput
|
||||
): Promise<boolean> => {
|
||||
validateInputs([directoryId, ZId], [organizationId, ZId], [data, ZFeedbackRecordDirectoryUpdateInput]);
|
||||
|
||||
try {
|
||||
const { name, projectIds, isArchived } = data;
|
||||
|
||||
const payload: Prisma.FeedbackRecordDirectoryUpdateInput = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
payload.name = name;
|
||||
}
|
||||
|
||||
if (isArchived !== undefined) {
|
||||
payload.isArchived = isArchived;
|
||||
}
|
||||
|
||||
if (projectIds !== undefined) {
|
||||
const currentDetails = await getFeedbackRecordDirectoryDetails(directoryId);
|
||||
const currentProjectIds = currentDetails?.projects.map((p) => p.projectId) ?? [];
|
||||
|
||||
payload.projects = await buildProjectAssignmentPayload(
|
||||
prisma,
|
||||
directoryId,
|
||||
projectIds,
|
||||
organizationId,
|
||||
currentProjectIds
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.feedbackRecordDirectory.update({
|
||||
where: { id: directoryId },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("DIRECTORY_NAME_DUPLICATE");
|
||||
}
|
||||
if (error.code === PrismaErrorType.RelatedRecordDoesNotExist) {
|
||||
throw new ResourceNotFoundError("FeedbackRecordDirectory", directoryId);
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the owning organization ID for a given directory.
|
||||
*
|
||||
* Used by server actions to determine the organization context for authorization checks.
|
||||
*
|
||||
* @param directoryId - The ID of the directory to look up.
|
||||
* @returns The organization ID that owns the directory.
|
||||
* @throws {ValidationError} If the directoryId fails input validation.
|
||||
* @throws {ResourceNotFoundError} If the directory does not exist.
|
||||
*/
|
||||
export const getOrganizationIdFromDirectoryId = async (directoryId: string): Promise<string> => {
|
||||
validateInputs([directoryId, ZId]);
|
||||
const directory = await prisma.feedbackRecordDirectory.findUnique({
|
||||
where: { id: directoryId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
|
||||
if (!directory) {
|
||||
throw new ResourceNotFoundError("FeedbackRecordDirectory", directoryId);
|
||||
}
|
||||
|
||||
return directory.organizationId;
|
||||
};
|
||||
54
apps/web/modules/ee/feedback-record-directory/page.tsx
Normal file
54
apps/web/modules/ee/feedback-record-directory/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { FeedbackRecordDirectoryView } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-view";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
export const FeedbackRecordDirectoriesPage = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const { isOwner, isManager } = getAccessFlags(currentUserMembership.role);
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
<OrganizationSettingsNavbar
|
||||
environmentId={params.environmentId}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={currentUserMembership.role}
|
||||
activeId="feedback-record-directories"
|
||||
/>
|
||||
</PageHeader>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.settings.feedback_record_directories.no_access")}
|
||||
</p>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
<OrganizationSettingsNavbar
|
||||
environmentId={params.environmentId}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={currentUserMembership.role}
|
||||
activeId="feedback-record-directories"
|
||||
/>
|
||||
</PageHeader>
|
||||
<FeedbackRecordDirectoryView
|
||||
organizationId={organization.id}
|
||||
membershipRole={currentUserMembership.role}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
export const ZFeedbackRecordDirectory = z.object({
|
||||
id: ZId,
|
||||
name: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
projectCount: z.number(),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordDirectory = z.infer<typeof ZFeedbackRecordDirectory>;
|
||||
|
||||
export const ZFeedbackRecordDirectoryDetails = z.object({
|
||||
id: ZId,
|
||||
name: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
organizationId: ZId,
|
||||
projects: z.array(
|
||||
z.object({
|
||||
projectId: ZId,
|
||||
projectName: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordDirectoryDetails = z.infer<typeof ZFeedbackRecordDirectoryDetails>;
|
||||
|
||||
export const ZFeedbackRecordDirectoryCreateInput = z.object({
|
||||
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED"),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordDirectoryCreateInput = z.infer<typeof ZFeedbackRecordDirectoryCreateInput>;
|
||||
|
||||
export const ZFeedbackRecordDirectoryUpdateInput = z.object({
|
||||
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED").optional(),
|
||||
projectIds: z.array(ZId).optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TFeedbackRecordDirectoryUpdateInput = z.infer<typeof ZFeedbackRecordDirectoryUpdateInput>;
|
||||
|
||||
/**
|
||||
* Translates a feedback record directory error code using the provided `t` function.
|
||||
* Returns the translated message, or the raw error code if no mapping exists.
|
||||
*/
|
||||
export const getTranslatedFeedbackRecordDirectoryError = (
|
||||
errorCode: string,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
switch (errorCode) {
|
||||
case "DIRECTORY_NAME_REQUIRED":
|
||||
return t("environments.settings.feedback_record_directories.error_directory_name_required");
|
||||
case "DIRECTORY_NAME_DUPLICATE":
|
||||
return t("environments.settings.feedback_record_directories.error_directory_name_duplicate");
|
||||
case "DIRECTORY_PROJECTS_INVALID_ORG":
|
||||
return t("environments.settings.feedback_record_directories.error_directory_projects_invalid_org");
|
||||
default:
|
||||
return errorCode;
|
||||
}
|
||||
};
|
||||
@@ -9,12 +9,13 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/50",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground text-primary",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
default: "bg-primary text-primary-foreground shadow enabled:hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm enabled:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm enabled:hover:bg-accent enabled:hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm enabled:hover:bg-secondary/50",
|
||||
ghost: "enabled:hover:bg-accent enabled:hover:text-accent-foreground text-primary",
|
||||
link: "text-primary underline-offset-4 enabled:hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
|
||||
@@ -25,6 +25,8 @@ interface DeleteDialogProps {
|
||||
onSave?: () => void;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
buttonLabel?: string;
|
||||
}
|
||||
|
||||
export const DeleteDialog = ({
|
||||
@@ -39,6 +41,8 @@ export const DeleteDialog = ({
|
||||
onSave,
|
||||
children,
|
||||
disabled,
|
||||
title,
|
||||
buttonLabel,
|
||||
}: DeleteDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -48,7 +52,7 @@ export const DeleteDialog = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
<div>
|
||||
<DialogTitle>{t("common.delete_what", { deleteWhat })}</DialogTitle>
|
||||
<DialogTitle>{title || t("common.delete_what", { deleteWhat })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.workspace.general.this_action_cannot_be_undone")}
|
||||
</DialogDescription>
|
||||
@@ -80,7 +84,7 @@ export const DeleteDialog = ({
|
||||
loading={isDeleting}
|
||||
disabled={disabled || isDeleting || isSaving}>
|
||||
<TrashIcon />
|
||||
{t("common.delete")}
|
||||
{buttonLabel || t("common.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from "@/modules/ui/components/command";
|
||||
import { Badge } from "@/modules/ui/components/multi-select/badge";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
|
||||
interface TOption<T> {
|
||||
value: T;
|
||||
@@ -18,12 +19,13 @@ interface MultiSelectProps<T extends string, K extends TOption<T>["value"][]> {
|
||||
onChange?: (selected: K) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
||||
props: Readonly<MultiSelectProps<T, K>>
|
||||
) {
|
||||
const { options, value, onChange, disabled = false, placeholder = "Select options..." } = props;
|
||||
const { options, value, onChange, disabled = false, placeholder, containerClassName } = props;
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -166,9 +168,11 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
||||
className={`relative overflow-visible bg-white ${disabled ? "cursor-not-allowed opacity-50" : ""}`}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`border-input ring-offset-background group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2 ${
|
||||
disabled ? "pointer-events-none" : "focus-within:ring-ring"
|
||||
}`}>
|
||||
className={cn(
|
||||
`border-input ring-offset-background group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2`,
|
||||
disabled ? "pointer-events-none" : "focus-within:ring-ring",
|
||||
containerClassName ?? ""
|
||||
)}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selected.map((option) => (
|
||||
<Badge key={option.value} className="rounded-md">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackRecordDirectory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"isArchived" BOOLEAN NOT NULL DEFAULT false,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "FeedbackRecordDirectory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FeedbackRecordDirectoryProject" (
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"feedbackRecordDirectoryId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "FeedbackRecordDirectoryProject_pkey" PRIMARY KEY ("feedbackRecordDirectoryId","projectId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "FeedbackRecordDirectory_organizationId_name_key" ON "FeedbackRecordDirectory"("organizationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FeedbackRecordDirectoryProject_projectId_idx" ON "FeedbackRecordDirectoryProject"("projectId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectory" ADD CONSTRAINT "FeedbackRecordDirectory_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectoryProject" ADD CONSTRAINT "FeedbackRecordDirectoryProject_feedbackRecordDirectoryId_fkey" FOREIGN KEY ("feedbackRecordDirectoryId") REFERENCES "FeedbackRecordDirectory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FeedbackRecordDirectoryProject" ADD CONSTRAINT "FeedbackRecordDirectoryProject_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -625,28 +625,29 @@ enum SurveyOverlay {
|
||||
/// @property recontactDays - Default recontact delay for surveys
|
||||
/// @property placement - Default widget placement for in-app surveys
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
environments Environment[]
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
environments Environment[]
|
||||
/// [Styling]
|
||||
styling Json @default("{\"allowStyleOverwrite\":true}")
|
||||
styling Json @default("{\"allowStyleOverwrite\":true}")
|
||||
/// [ProjectConfig]
|
||||
config Json @default("{}")
|
||||
recontactDays Int @default(7)
|
||||
linkSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in link surveys
|
||||
inAppSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in in-app surveys
|
||||
placement WidgetPlacement @default(bottomRight)
|
||||
clickOutsideClose Boolean @default(true)
|
||||
overlay SurveyOverlay @default(none)
|
||||
languages Language[]
|
||||
config Json @default("{}")
|
||||
recontactDays Int @default(7)
|
||||
linkSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in link surveys
|
||||
inAppSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in in-app surveys
|
||||
placement WidgetPlacement @default(bottomRight)
|
||||
clickOutsideClose Boolean @default(true)
|
||||
overlay SurveyOverlay @default(none)
|
||||
languages Language[]
|
||||
/// [Logo]
|
||||
logo Json?
|
||||
projectTeams ProjectTeam[]
|
||||
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
|
||||
logo Json?
|
||||
projectTeams ProjectTeam[]
|
||||
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
|
||||
feedbackRecordDirectoryProjects FeedbackRecordDirectoryProject[]
|
||||
|
||||
@@unique([organizationId, name])
|
||||
}
|
||||
@@ -663,19 +664,20 @@ model Project {
|
||||
/// @property whitelabel - Whitelabel configuration for the organization
|
||||
/// @property isAIEnabled - Controls access to AI-powered features
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
memberships Membership[]
|
||||
projects Project[]
|
||||
billing OrganizationBilling?
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
memberships Membership[]
|
||||
projects Project[]
|
||||
billing OrganizationBilling?
|
||||
/// [OrganizationWhitelabel]
|
||||
whitelabel Json @default("{}")
|
||||
invites Invite[]
|
||||
isAIEnabled Boolean @default(false)
|
||||
teams Team[]
|
||||
apiKeys ApiKey[]
|
||||
whitelabel Json @default("{}")
|
||||
invites Invite[]
|
||||
isAIEnabled Boolean @default(false)
|
||||
teams Team[]
|
||||
apiKeys ApiKey[]
|
||||
feedbackRecordDirectories FeedbackRecordDirectory[]
|
||||
}
|
||||
|
||||
/// Stores billing and Stripe synchronization data for an organization.
|
||||
@@ -1023,3 +1025,41 @@ model ProjectTeam {
|
||||
@@id([projectId, teamId])
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
/// Represents a feedback record directory (Hub tenant) owned by an organization.
|
||||
/// Directories group feedback data and are assigned to workspaces for access control.
|
||||
///
|
||||
/// @property id - Unique identifier for the directory
|
||||
/// @property name - Display name of the directory
|
||||
/// @property isArchived - Soft delete flag
|
||||
/// @property organization - The parent organization
|
||||
/// @property projects - Workspaces assigned to this directory
|
||||
model FeedbackRecordDirectory {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
name String
|
||||
isArchived Boolean @default(false)
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
projects FeedbackRecordDirectoryProject[]
|
||||
|
||||
@@unique([organizationId, name])
|
||||
}
|
||||
|
||||
/// Links feedback record directories to projects (workspaces).
|
||||
/// Manages which workspaces can access a given directory.
|
||||
///
|
||||
/// @property feedbackRecordDirectory - The directory being accessed
|
||||
/// @property project - The workspace receiving access
|
||||
model FeedbackRecordDirectoryProject {
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
feedbackRecordDirectoryId String
|
||||
feedbackRecordDirectory FeedbackRecordDirectory @relation(fields: [feedbackRecordDirectoryId], references: [id], onDelete: Cascade)
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([feedbackRecordDirectoryId, projectId])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ checksums:
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/language_switch: fd72a9ada13f672f4fd5da863b22cc46
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/no_results_found: 5518f2865757dc73900aa03ef8be6934
|
||||
common/open_in_new_tab: 6844e4922a7a40a7ee25c10ea109cdeb
|
||||
common/people_responded: b685fb877090d8658db724ad07a0dbd8
|
||||
common/please_retry_now_or_try_again_later: 949a3841e2eb01fa249790a42bf23aa5
|
||||
@@ -22,6 +23,7 @@ checksums:
|
||||
common/respondents_will_not_see_this_card: 18c3dd44d6ff6ca2310ad196b84f30d3
|
||||
common/retry: 6e44d18639560596569a1278f9c83676
|
||||
common/retrying: 40989361ea5f6b95897b95ac928b5bd9
|
||||
common/search: fe877a75eac472fc5b188c135c78a558
|
||||
common/select_option: d68a0fb9afd0817dc31b3e9cb11855cb
|
||||
common/select_options: d5a80087e889848e0fed3f1be359366f
|
||||
common/sending_responses: 244f1aebc3f6a101ae2f8b630d7967ec
|
||||
|
||||
Reference in New Issue
Block a user