feat: feedback record directories (#7592)

This commit is contained in:
Anshuman Pandey
2026-03-27 16:48:20 +05:30
committed by GitHub
parent 81272b96e1
commit 029e069af6
37 changed files with 2086 additions and 46 deletions

View File

@@ -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"),

View File

@@ -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"),

View File

@@ -0,0 +1 @@
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";

View File

@@ -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

View File

@@ -22,6 +22,7 @@ export type AuditLoggingCtx = {
quotaId?: string;
teamId?: string;
integrationId?: string;
feedbackRecordDirectoryId?: string;
};
export type ActionClientCtx = {

View File

@@ -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.",

View File

@@ -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!"
}
}
}

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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": "これはあなたの唯一の組織です。削除できません。まず新しい組織を作成してください。",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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": "Это ваша единственная организация, её нельзя удалить. Сначала создайте новую организацию.",

View File

@@ -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.",

View File

@@ -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": "这是 您 唯一的 组织,不可 删除。请 先 创建一个新的 组织。",

View File

@@ -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": "這是您唯一的組織,無法刪除。請先建立新組織。",

View File

@@ -290,6 +290,9 @@ export const withAuditLogging = <
case "quota":
targetId = auditLoggingCtx.quotaId;
break;
case "feedbackRecordDirectory":
targetId = auditLoggingCtx.feedbackRecordDirectoryId;
break;
default:
targetId = UNKNOWN_DATA;
break;

View File

@@ -25,6 +25,7 @@ export const ZAuditTarget = z.enum([
"integration",
"file",
"quota",
"feedbackRecordDirectory",
]);
export const ZAuditAction = z.enum([
"created",

View 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;
})
);

View File

@@ -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>
);
};

View File

@@ -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>
)}
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
)}
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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);
});
});
});

View File

@@ -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;
};

View 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>
);
};

View File

@@ -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;
}
};

View File

@@ -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",

View File

@@ -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>

View File

@@ -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">

View File

@@ -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;

View File

@@ -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])
}

View File

@@ -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