feat: (dashboards) listing page (#7330)

This commit is contained in:
Theodór Tómas
2026-02-23 20:26:03 +07:00
committed by GitHub
parent 5ccb4af249
commit d670d5de31
30 changed files with 1114 additions and 34 deletions
@@ -1,9 +1 @@
const DashboardsPage = () => {
return (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
Dashboards will appear here.
</div>
);
};
export default DashboardsPage;
export { DashboardsListPage as default } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
@@ -1,16 +0,0 @@
import type { ReactNode } from "react";
import { getTranslate } from "@/lingodotdev/server";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
const AnalysisLayout = async (props: { children: ReactNode; params: Promise<{ environmentId: string }> }) => {
const { environmentId } = await props.params;
const t = await getTranslate();
return (
<AnalysisPageLayout pageTitle={t("common.analysis")} environmentId={environmentId}>
{props.children}
</AnalysisPageLayout>
);
};
export default AnalysisLayout;
+18
View File
@@ -153,6 +153,7 @@ checksums:
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
common/count_responses: 690118a456c01c5b4d437ae82b50b131
common/create: 757ccd28dd533ff3a933355273c1e32a
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
common/create_survey: 1cfbba08d34876566d84b2960054a987
@@ -162,6 +163,7 @@ checksums:
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
common/dashboard: c9380ea68c8c76ea451bd9613329a07c
common/dashboards: 4bc47e48559a6b688684dcb7ac4babc9
common/date: 56f41c5d30a76295bb087b20b7bee4c3
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
@@ -279,6 +281,7 @@ checksums:
common/on: 1929bcf2fba8003c043b446a851bcb4f
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
common/open_options: a4578c0afbfdf4a76d5952a53085b72a
common/option_id: ed21d97b8ab035ba89fb3f5f073229bd
common/option_ids: e68c25215ce81ea7ad82ff7be0a0bf2d
common/optional: 396fb9a0472daf401c392bdc3e248943
@@ -577,6 +580,21 @@ checksums:
environments/actions/you_can_track_code_action_anywhere_in_your_app_using: 3c0bbf160b8ddbeef142403103b70554
environments/actions/your_survey_would_be_shown_on_this_url: 766fdeeb52d170c156af5d035a1f8c37
environments/actions/your_survey_would_not_be_shown: af44fe160f449ff9557ebe5d3686832d
environments/analysis/dashboards/create_dashboard: 9396aec1ea4a9b05ada94483655d1373
environments/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
environments/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
environments/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
environments/analysis/dashboards/dashboard_name: a2d344bc03f27706b42d7d6a8d0fc752
environments/analysis/dashboards/dashboard_name_placeholder: 02954eeb5671f1c00e3f69b47319916e
environments/analysis/dashboards/delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
environments/analysis/dashboards/delete_failed: b108acc28b1f9abcb544a358a958b54b
environments/analysis/dashboards/delete_success: 9d161634daab9ea9d17fbfb413eeeffa
environments/analysis/dashboards/description_optional: d5519551a79f18fc414dc127b773485f
environments/analysis/dashboards/description_placeholder: 90a599e6b1695e2b026fb1300d1d5903
environments/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
environments/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
environments/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
environments/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
environments/connect/congrats: c2f5b597aabdf298cf9f0452863e2dc6
environments/connect/connection_successful_message: fa1f29883e15e8697c6c477bdf5cb645
environments/connect/do_it_later: ab4accfbe53d924ab3ffaf9ea78a75f3
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
"create": "Erstellen",
"create_new_organization": "Neue Organisation erstellen",
"create_segment": "Segment erstellen",
"create_survey": "Umfrage erstellen",
@@ -189,6 +190,7 @@
"created_by": "Erstellt von",
"customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Datum",
"days": "Tage",
@@ -306,6 +308,7 @@
"on": "An",
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
"open_options": "Optionen öffnen",
"option_id": "Option-ID",
"option_ids": "Option-IDs",
"optional": "Optional",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "Ihre Umfrage wäre unter dieser URL angezeigt.",
"your_survey_would_not_be_shown": "Ihre Umfrage wäre nicht angezeigt."
},
"analysis": {
"dashboards": {
"create_dashboard": "Dashboard erstellen",
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
"create_failed": "Dashboard konnte nicht erstellt werden",
"create_success": "Dashboard erfolgreich erstellt!",
"dashboard_name": "Dashboard-Name",
"dashboard_name_placeholder": "Mein Dashboard",
"delete_confirmation": "Bist du sicher, dass du dieses Dashboard löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_failed": "Dashboard konnte nicht gelöscht werden",
"delete_success": "Dashboard erfolgreich gelöscht",
"description_optional": "Beschreibung (optional)",
"description_placeholder": "Dashboard-Beschreibung",
"duplicate_failed": "Dashboard konnte nicht dupliziert werden",
"duplicate_success": "Dashboard erfolgreich dupliziert!",
"no_dashboards_found": "Keine Dashboards gefunden.",
"please_enter_name": "Bitte gib einen Dashboard-Namen ein"
}
},
"connect": {
"congrats": "Glückwunsch!",
"connection_successful_message": "Gut gemacht! Wir sind verbunden.",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
"create": "Create",
"create_new_organization": "Create new organization",
"create_segment": "Create segment",
"create_survey": "Create survey",
@@ -189,6 +190,7 @@
"created_by": "Created by",
"customer_success": "Customer Success",
"dark_overlay": "Dark overlay",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Date",
"days": "days",
@@ -306,6 +308,7 @@
"on": "On",
"only_one_file_allowed": "Only one file is allowed",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
"open_options": "Open options",
"option_id": "Option ID",
"option_ids": "Option IDs",
"optional": "Optional",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.",
"your_survey_would_not_be_shown": "Your survey would not be shown."
},
"analysis": {
"dashboards": {
"create_dashboard": "Create Dashboard",
"create_dashboard_description": "Enter a name for your new dashboard.",
"create_failed": "Failed to create dashboard",
"create_success": "Dashboard created successfully!",
"dashboard_name": "Dashboard Name",
"dashboard_name_placeholder": "My dashboard",
"delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
"delete_failed": "Failed to delete dashboard",
"delete_success": "Dashboard deleted successfully",
"description_optional": "Description (Optional)",
"description_placeholder": "Dashboard description",
"duplicate_failed": "Failed to duplicate dashboard",
"duplicate_success": "Dashboard duplicated successfully!",
"no_dashboards_found": "No dashboards found.",
"please_enter_name": "Please enter a dashboard name"
}
},
"connect": {
"congrats": "Congrats!",
"connection_successful_message": "Well done! We are connected.",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {{value} contacto} other {{value} contactos}}",
"count_responses": "{value, plural, one {{value} respuesta} other {{value} respuestas}}",
"create": "Crear",
"create_new_organization": "Crear organización nueva",
"create_segment": "Crear segmento",
"create_survey": "Crear encuesta",
@@ -189,6 +190,7 @@
"created_by": "Creado por",
"customer_success": "Éxito del cliente",
"dark_overlay": "Superposición oscura",
"dashboard": "Panel de control",
"dashboards": "Paneles",
"date": "Fecha",
"days": "días",
@@ -306,6 +308,7 @@
"on": "Activado",
"only_one_file_allowed": "Solo se permite un archivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
"open_options": "Abrir opciones",
"option_id": "ID de opción",
"option_ids": "IDs de opciones",
"optional": "Opcional",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "Tu encuesta se mostraría en esta URL.",
"your_survey_would_not_be_shown": "Tu encuesta no se mostraría."
},
"analysis": {
"dashboards": {
"create_dashboard": "Crear panel de control",
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
"create_failed": "Error al crear el panel de control",
"create_success": "Panel de control creado correctamente",
"dashboard_name": "Nombre del panel de control",
"dashboard_name_placeholder": "Mi panel de control",
"delete_confirmation": "¿Estás seguro de que quieres eliminar este panel de control? Esta acción no se puede deshacer.",
"delete_failed": "Error al eliminar el panel de control",
"delete_success": "Panel de control eliminado correctamente",
"description_optional": "Descripción (opcional)",
"description_placeholder": "Descripción del panel de control",
"duplicate_failed": "Error al duplicar el panel de control",
"duplicate_success": "Panel de control duplicado correctamente",
"no_dashboards_found": "No se han encontrado paneles de control.",
"please_enter_name": "Por favor, introduce un nombre para el panel de control"
}
},
"connect": {
"congrats": "¡Enhorabuena!",
"connection_successful_message": "¡Bien hecho! Estamos conectados.",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attributs}}",
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
"count_responses": "{value, plural, other {# réponses}}",
"create": "Créer",
"create_new_organization": "Créer une nouvelle organisation",
"create_segment": "Créer un segment",
"create_survey": "Créer un sondage",
@@ -189,6 +190,7 @@
"created_by": "Créé par",
"customer_success": "Succès Client",
"dark_overlay": "Foncée",
"dashboard": "Tableau de bord",
"dashboards": "Tableaux de bord",
"date": "Date",
"days": "jours",
@@ -306,6 +308,7 @@
"on": "Sur",
"only_one_file_allowed": "Un seul fichier est autorisé",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
"open_options": "Ouvrir les options",
"option_id": "Identifiant de l'option",
"option_ids": "Identifiants des options",
"optional": "Facultatif",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
"your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée."
},
"analysis": {
"dashboards": {
"create_dashboard": "Créer un tableau de bord",
"create_dashboard_description": "Saisissez un nom pour votre nouveau tableau de bord.",
"create_failed": "Échec de la création du tableau de bord",
"create_success": "Tableau de bord créé avec succès!",
"dashboard_name": "Nom du tableau de bord",
"dashboard_name_placeholder": "Mon tableau de bord",
"delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce tableau de bord? Cette action est irréversible.",
"delete_failed": "Échec de la suppression du tableau de bord",
"delete_success": "Tableau de bord supprimé avec succès",
"description_optional": "Description (facultatif)",
"description_placeholder": "Description du tableau de bord",
"duplicate_failed": "Échec de la duplication du tableau de bord",
"duplicate_success": "Tableau de bord dupliqué avec succès!",
"no_dashboards_found": "Aucun tableau de bord trouvé.",
"please_enter_name": "Veuillez saisir un nom de tableau de bord"
}
},
"connect": {
"congrats": "Félicitations !",
"connection_successful_message": "Bien joué ! Nous sommes connectés.",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} attribútum} other {{value} attribútum}}",
"count_contacts": "{value, plural, one {{value} partner} other {{value} partner}}",
"count_responses": "{value, plural, one {{value} válasz} other {{value} válasz}}",
"create": "Létrehozás",
"create_new_organization": "Új szervezet létrehozása",
"create_segment": "Szakasz létrehozása",
"create_survey": "Kérdőív létrehozása",
@@ -189,6 +190,7 @@
"created_by": "Létrehozta",
"customer_success": "Ügyfélsiker",
"dark_overlay": "Sötét rávetítés",
"dashboard": "Vezérlőpult",
"dashboards": "Irányítópultok",
"date": "Dátum",
"days": "napok",
@@ -306,6 +308,7 @@
"on": "Be",
"only_one_file_allowed": "Csak egy fájl engedélyezett",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
"open_options": "Beállítások megnyitása",
"option_id": "Választásazonosító",
"option_ids": "Választásazonosítók",
"optional": "Elhagyható",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "A kérdőív ezen az URL-en jelenne meg.",
"your_survey_would_not_be_shown": "A kérdőív nem jelenne meg."
},
"analysis": {
"dashboards": {
"create_dashboard": "Vezérlőpult létrehozása",
"create_dashboard_description": "Adjon nevet az új vezérlőpultnak.",
"create_failed": "A vezérlőpult létrehozása sikertelen",
"create_success": "A vezérlőpult sikeresen létrehozva!",
"dashboard_name": "Vezérlőpult neve",
"dashboard_name_placeholder": "Saját vezérlőpult",
"delete_confirmation": "Biztosan törölni szeretné ezt a vezérlőpultot? Ez a művelet nem vonható vissza.",
"delete_failed": "A vezérlőpult törlése sikertelen",
"delete_success": "A vezérlőpult sikeresen törölve",
"description_optional": "Leírás (opcionális)",
"description_placeholder": "Vezérlőpult leírása",
"duplicate_failed": "A vezérlőpult másolása sikertelen",
"duplicate_success": "A vezérlőpult sikeresen lemásolva!",
"no_dashboards_found": "Nem található vezérlőpult.",
"please_enter_name": "Kérjük, adjon nevet a vezérlőpultnak"
}
},
"connect": {
"congrats": "Gratulálunk!",
"connection_successful_message": "Szép munka! Kapcsolódtunk.",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, other {{value}個の属性}}",
"count_contacts": "{count, plural, other {# 件の連絡先}}",
"count_responses": "{count, plural, other {# 件の回答}}",
"create": "作成",
"create_new_organization": "新しい組織を作成",
"create_segment": "セグメントを作成",
"create_survey": "フォームを作成",
@@ -189,6 +190,7 @@
"created_by": "作成者",
"customer_success": "カスタマーサクセス",
"dark_overlay": "暗いオーバーレイ",
"dashboard": "ダッシュボード",
"dashboards": "ダッシュボード",
"date": "日付",
"days": "日",
@@ -306,6 +308,7 @@
"on": "オン",
"only_one_file_allowed": "ファイルは1つのみ許可されています",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
"open_options": "オプションを開く",
"option_id": "オプションID",
"option_ids": "オプションID",
"optional": "任意",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "あなたのフォームはこのURLに表示されます。",
"your_survey_would_not_be_shown": "あなたのフォームは表示されません。"
},
"analysis": {
"dashboards": {
"create_dashboard": "ダッシュボードを作成",
"create_dashboard_description": "新しいダッシュボードの名前を入力してください。",
"create_failed": "ダッシュボードの作成に失敗しました",
"create_success": "ダッシュボードを正常に作成しました!",
"dashboard_name": "ダッシュボード名",
"dashboard_name_placeholder": "マイダッシュボード",
"delete_confirmation": "このダッシュボードを削除してもよろしいですか?この操作は元に戻せません。",
"delete_failed": "ダッシュボードの削除に失敗しました",
"delete_success": "ダッシュボードを正常に削除しました",
"description_optional": "説明(任意)",
"description_placeholder": "ダッシュボードの説明",
"duplicate_failed": "ダッシュボードの複製に失敗しました",
"duplicate_success": "ダッシュボードを正常に複製しました!",
"no_dashboards_found": "ダッシュボードが見つかりません。",
"please_enter_name": "ダッシュボード名を入力してください"
}
},
"connect": {
"congrats": "おめでとうございます!",
"connection_successful_message": "うまくいきました!接続されました。",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} attribuut} other {{value} attributen}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacten}}",
"count_responses": "{value, plural, one {{value} reactie} other {{value} reacties}}",
"create": "Creëren",
"create_new_organization": "Creëer een nieuwe organisatie",
"create_segment": "Segment maken",
"create_survey": "Enquête maken",
@@ -189,6 +190,7 @@
"created_by": "Gemaakt door",
"customer_success": "Klant succes",
"dark_overlay": "Donkere overlay",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Datum",
"days": "dagen",
@@ -306,6 +308,7 @@
"on": "Op",
"only_one_file_allowed": "Er is slechts één bestand toegestaan",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
"open_options": "Opties openen",
"option_id": "Optie-ID",
"option_ids": "Optie-ID's",
"optional": "Optioneel",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "Uw enquête wordt op deze URL weergegeven.",
"your_survey_would_not_be_shown": "Uw enquête wordt niet getoond."
},
"analysis": {
"dashboards": {
"create_dashboard": "Dashboard creëren",
"create_dashboard_description": "Voer een naam in voor je nieuwe dashboard.",
"create_failed": "Dashboard creëren mislukt",
"create_success": "Dashboard succesvol aangemaakt!",
"dashboard_name": "Dashboardnaam",
"dashboard_name_placeholder": "Mijn dashboard",
"delete_confirmation": "Weet je zeker dat je dit dashboard wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"delete_failed": "Dashboard verwijderen mislukt",
"delete_success": "Dashboard succesvol verwijderd",
"description_optional": "Beschrijving (optioneel)",
"description_placeholder": "Dashboardbeschrijving",
"duplicate_failed": "Dashboard dupliceren mislukt",
"duplicate_success": "Dashboard succesvol gedupliceerd!",
"no_dashboards_found": "Geen dashboards gevonden.",
"please_enter_name": "Voer een dashboardnaam in"
}
},
"connect": {
"congrats": "Gefeliciteerd!",
"connection_successful_message": "Goed gedaan! We zijn verbonden.",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
"count_responses": "{value, plural, other {# respostas}}",
"create": "Criar",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar pesquisa",
@@ -189,6 +190,7 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "sobreposição escura",
"dashboard": "Painel",
"dashboards": "Painéis",
"date": "Encontro",
"days": "dias",
@@ -306,6 +308,7 @@
"on": "ligado",
"only_one_file_allowed": "É permitido apenas um arquivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
"open_options": "Abrir opções",
"option_id": "ID da opção",
"option_ids": "IDs da Opção",
"optional": "Opcional",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.",
"your_survey_would_not_be_shown": "Sua pesquisa não seria exibida."
},
"analysis": {
"dashboards": {
"create_dashboard": "Criar painel",
"create_dashboard_description": "Digite um nome para o seu novo painel.",
"create_failed": "Falha ao criar painel",
"create_success": "Painel criado com sucesso!",
"dashboard_name": "Nome do painel",
"dashboard_name_placeholder": "Meu painel",
"delete_confirmation": "Tem certeza de que deseja excluir este painel? Esta ação não pode ser desfeita.",
"delete_failed": "Falha ao excluir painel",
"delete_success": "Painel excluído com sucesso",
"description_optional": "Descrição (opcional)",
"description_placeholder": "Descrição do painel",
"duplicate_failed": "Falha ao duplicar painel",
"duplicate_success": "Painel duplicado com sucesso!",
"no_dashboards_found": "Nenhum painel encontrado.",
"please_enter_name": "Por favor, digite um nome para o painel"
}
},
"connect": {
"congrats": "Parabéns!",
"connection_successful_message": "Mandou bem! Estamos conectados.",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
"count_responses": "{value, plural, other {# respostas}}",
"create": "Criar",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar inquérito",
@@ -189,6 +190,7 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "Sobreposição escura",
"dashboard": "Painel",
"dashboards": "Dashboards",
"date": "Data",
"days": "dias",
@@ -306,6 +308,7 @@
"on": "Ligado",
"only_one_file_allowed": "Apenas um ficheiro é permitido",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
"open_options": "Abrir opções",
"option_id": "ID de Opção",
"option_ids": "IDs de Opção",
"optional": "Opcional",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.",
"your_survey_would_not_be_shown": "O seu inquérito não seria mostrado."
},
"analysis": {
"dashboards": {
"create_dashboard": "Criar painel",
"create_dashboard_description": "Introduza um nome para o seu novo painel.",
"create_failed": "Falha ao criar painel",
"create_success": "Painel criado com sucesso!",
"dashboard_name": "Nome do painel",
"dashboard_name_placeholder": "O meu painel",
"delete_confirmation": "Tem a certeza de que pretende eliminar este painel? Esta ação não pode ser revertida.",
"delete_failed": "Falha ao eliminar painel",
"delete_success": "Painel eliminado com sucesso",
"description_optional": "Descrição (opcional)",
"description_placeholder": "Descrição do painel",
"duplicate_failed": "Falha ao duplicar painel",
"duplicate_success": "Painel duplicado com sucesso!",
"no_dashboards_found": "Nenhum painel encontrado.",
"please_enter_name": "Por favor, introduza um nome para o painel"
}
},
"connect": {
"congrats": "Parabéns!",
"connection_successful_message": "Muito bem! Estamos ligados.",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} atribut} few {{value} atribute} other {{value} de atribute}}",
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
"create": "Creează",
"create_new_organization": "Creează organizație nouă",
"create_segment": "Creați segment",
"create_survey": "Creează sondaj",
@@ -189,6 +190,7 @@
"created_by": "Creat de",
"customer_success": "Succesul Clientului",
"dark_overlay": "Suprapunere întunecată",
"dashboard": "Tablou de bord",
"dashboards": "Tablouri de bord",
"date": "Dată",
"days": "zile",
@@ -306,6 +308,7 @@
"on": "Pe",
"only_one_file_allowed": "Este permis doar un fișier",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
"open_options": "Deschide opțiunile",
"option_id": "ID opțiune",
"option_ids": "ID-uri opțiuni",
"optional": "Opțional",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "Sondajul dumneavoastră ar fi afișat pe acest URL.",
"your_survey_would_not_be_shown": "Sondajul dumneavoastră nu va fi afișat."
},
"analysis": {
"dashboards": {
"create_dashboard": "Creează tablou de bord",
"create_dashboard_description": "Introdu un nume pentru noul tău tablou de bord.",
"create_failed": "Crearea tabloului de bord a eșuat",
"create_success": "Tablou de bord creat cu succes!",
"dashboard_name": "Nume tablou de bord",
"dashboard_name_placeholder": "Tabloul meu de bord",
"delete_confirmation": "Ești sigur că vrei să ștergi acest tablou de bord? Această acțiune nu poate fi anulată.",
"delete_failed": "Ștergerea tabloului de bord a eșuat",
"delete_success": "Tablou de bord șters cu succes",
"description_optional": "Descriere (opțional)",
"description_placeholder": "Descriere tablou de bord",
"duplicate_failed": "Duplicarea tabloului de bord a eșuat",
"duplicate_success": "Tablou de bord duplicat cu succes!",
"no_dashboards_found": "Nu s-a găsit niciun tablou de bord.",
"please_enter_name": "Te rugăm să introduci un nume pentru tablou de bord"
}
},
"connect": {
"congrats": "Felicitări!",
"connection_successful_message": "Bravo! Suntem conectați.",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} атрибут} few {{value} атрибута} many {{value} атрибутов} other {{value} атрибута}}",
"count_contacts": "{value, plural, one {{value} контакт} few {{value} контакта} many {{value} контактов} other {{value} контактов}}",
"count_responses": "{value, plural, one {{value} ответ} few {{value} ответа} many {{value} ответов} other {{value} ответов}}",
"create": "Создать",
"create_new_organization": "Создать новую организацию",
"create_segment": "Создать сегмент",
"create_survey": "Создать опрос",
@@ -189,6 +190,7 @@
"created_by": "Создано пользователем",
"customer_success": "Customer Success",
"dark_overlay": "Тёмный оверлей",
"dashboard": "Панель управления",
"dashboards": "Дашборды",
"date": "Дата",
"days": "дни",
@@ -306,6 +308,7 @@
"on": "Вкл.",
"only_one_file_allowed": "Разрешён только один файл",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
"open_options": "Открыть параметры",
"option_id": "ID опции",
"option_ids": "ID опций",
"optional": "Необязательно",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "Ваш опрос будет отображаться по этому URL.",
"your_survey_would_not_be_shown": "Ваш опрос не будет отображаться."
},
"analysis": {
"dashboards": {
"create_dashboard": "Создать панель управления",
"create_dashboard_description": "Введите название для новой панели управления.",
"create_failed": "Не удалось создать панель управления",
"create_success": "Панель управления успешно создана!",
"dashboard_name": "Название панели управления",
"dashboard_name_placeholder": "Моя панель управления",
"delete_confirmation": "Ты уверен, что хочешь удалить эту панель управления? Это действие нельзя отменить.",
"delete_failed": "Не удалось удалить панель управления",
"delete_success": "Панель управления успешно удалена",
"description_optional": "Описание (необязательно)",
"description_placeholder": "Описание панели управления",
"duplicate_failed": "Не удалось дублировать панель управления",
"duplicate_success": "Панель управления успешно продублирована!",
"no_dashboards_found": "Панели управления не найдены.",
"please_enter_name": "Пожалуйста, введите название панели управления"
}
},
"connect": {
"congrats": "Поздравляем!",
"connection_successful_message": "Отлично! Мы подключены.",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attribut}}",
"count_contacts": "{value, plural, one {{value} kontakt} other {{value} kontakter}}",
"count_responses": "{value, plural, one {{value} svar} other {{value} svar}}",
"create": "Skapa",
"create_new_organization": "Skapa ny organisation",
"create_segment": "Skapa segment",
"create_survey": "Skapa enkät",
@@ -189,6 +190,7 @@
"created_by": "Skapad av",
"customer_success": "Kundframgång",
"dark_overlay": "Mörkt överlägg",
"dashboard": "Instrumentpanel",
"dashboards": "Instrumentpaneler",
"date": "Datum",
"days": "dagar",
@@ -306,6 +308,7 @@
"on": "På",
"only_one_file_allowed": "Endast en fil är tillåten",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
"open_options": "Öppna alternativ",
"option_id": "Alternativ-ID",
"option_ids": "Alternativ-ID:n",
"optional": "Valfritt",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "Din enkät skulle visas på denna URL.",
"your_survey_would_not_be_shown": "Din enkät skulle inte visas."
},
"analysis": {
"dashboards": {
"create_dashboard": "Skapa instrumentpanel",
"create_dashboard_description": "Ange ett namn för din nya instrumentpanel.",
"create_failed": "Det gick inte att skapa instrumentpanelen",
"create_success": "Instrumentpanelen har skapats!",
"dashboard_name": "Instrumentpanelens namn",
"dashboard_name_placeholder": "Min instrumentpanel",
"delete_confirmation": "Är du säker på att du vill ta bort den här instrumentpanelen? Den här åtgärden kan inte ångras.",
"delete_failed": "Det gick inte att ta bort instrumentpanelen",
"delete_success": "Instrumentpanelen har tagits bort",
"description_optional": "Beskrivning (valfritt)",
"description_placeholder": "Beskrivning av instrumentpanelen",
"duplicate_failed": "Det gick inte att duplicera instrumentpanelen",
"duplicate_success": "Instrumentpanelen har duplicerats!",
"no_dashboards_found": "Inga instrumentpaneler hittades.",
"please_enter_name": "Ange ett namn på instrumentpanelen"
}
},
"connect": {
"congrats": "Grattis!",
"connection_successful_message": "Bra gjort! Vi är anslutna.",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} 个属性} other {{value} 个属性}}",
"count_contacts": "{value, plural, other {{value} 联系人} }",
"count_responses": "{value, plural, other {{value} 回复} }",
"create": "创建",
"create_new_organization": "创建 新的 组织",
"create_segment": "创建 细分",
"create_survey": "创建 调查",
@@ -189,6 +190,7 @@
"created_by": "由 创建",
"customer_success": "客户成功",
"dark_overlay": "深色遮罩层",
"dashboard": "Dashboard",
"dashboards": "仪表盘",
"date": "日期",
"days": "天",
@@ -306,6 +308,7 @@
"on": "开启",
"only_one_file_allowed": "只 允许 一个 文件",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
"open_options": "打开选项",
"option_id": "选项 ID",
"option_ids": "选项 ID",
"optional": "可选",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "您的 调查 会 显示 在 此 URL 上",
"your_survey_would_not_be_shown": "您的 调查 不会 显示。"
},
"analysis": {
"dashboards": {
"create_dashboard": "创建 Dashboard",
"create_dashboard_description": "请输入新 Dashboard 的名称。",
"create_failed": "创建 Dashboard 失败",
"create_success": "Dashboard 创建成功!",
"dashboard_name": "Dashboard 名称",
"dashboard_name_placeholder": "我的 Dashboard",
"delete_confirmation": "确定要删除此 Dashboard 吗?此操作无法撤销。",
"delete_failed": "删除 Dashboard 失败",
"delete_success": "Dashboard 删除成功",
"description_optional": "描述(可选)",
"description_placeholder": "Dashboard 描述",
"duplicate_failed": "复制 Dashboard 失败",
"duplicate_success": "Dashboard 复制成功!",
"no_dashboards_found": "未找到 Dashboard。",
"please_enter_name": "请输入 Dashboard 名称"
}
},
"connect": {
"congrats": "恭喜!",
"connection_successful_message": "做得好 !我们 已经 连接。",
+22
View File
@@ -180,6 +180,7 @@
"count_attributes": "{value, plural, one {{value} 個屬性} other {{value} 個屬性}}",
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
"count_responses": "{value, plural, other {{value} 回應} }",
"create": "建立",
"create_new_organization": "建立新組織",
"create_segment": "建立區隔",
"create_survey": "建立問卷",
@@ -189,6 +190,7 @@
"created_by": "建立者",
"customer_success": "客戶成功",
"dark_overlay": "深色覆蓋",
"dashboard": "儀表板",
"dashboards": "儀表板",
"date": "日期",
"days": "天",
@@ -306,6 +308,7 @@
"on": "開啟",
"only_one_file_allowed": "僅允許一個檔案",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
"open_options": "開啟選項",
"option_id": "選項 ID",
"option_ids": "選項 IDs",
"optional": "選填",
@@ -610,6 +613,25 @@
"your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。",
"your_survey_would_not_be_shown": "您的問卷將不會顯示。"
},
"analysis": {
"dashboards": {
"create_dashboard": "建立儀表板",
"create_dashboard_description": "請輸入新儀表板的名稱。",
"create_failed": "建立儀表板失敗",
"create_success": "儀表板建立成功!",
"dashboard_name": "儀表板名稱",
"dashboard_name_placeholder": "我的儀表板",
"delete_confirmation": "你確定要刪除此儀表板嗎?此操作無法復原。",
"delete_failed": "刪除儀表板失敗",
"delete_success": "儀表板刪除成功",
"description_optional": "描述(選填)",
"description_placeholder": "儀表板描述",
"duplicate_failed": "複製儀表板失敗",
"duplicate_success": "儀表板複製成功!",
"no_dashboards_found": "找不到儀表板。",
"please_enter_name": "請輸入儀表板名稱"
}
},
"connect": {
"congrats": "恭喜!",
"connection_successful_message": "做得好!我們已連線。",
@@ -1,5 +1,8 @@
"use server";
// eslint-disable-next-line
// TODO: remove revalidatePath and use revalidateTag instead once this has become stable: https://nextjs.org/docs/app/api-reference/directives/use-cache#usage
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZWidgetLayout } from "@formbricks/types/dashboard";
@@ -12,6 +15,7 @@ import {
addChartToDashboard,
createDashboard,
deleteDashboard,
duplicateDashboard,
getDashboard,
getDashboards,
updateDashboard,
@@ -47,6 +51,8 @@ export const createDashboardAction = authenticatedActionClient.schema(ZCreateDas
createdBy: ctx.user.id,
});
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = dashboard.id;
@@ -119,6 +125,8 @@ export const deleteDashboardAction = authenticatedActionClient.schema(ZDeleteDas
const dashboard = await deleteDashboard(parsedInput.dashboardId, projectId);
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
@@ -128,6 +136,41 @@ export const deleteDashboardAction = authenticatedActionClient.schema(ZDeleteDas
)
);
const ZDuplicateDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const duplicateDashboardAction = authenticatedActionClient.schema(ZDuplicateDashboardAction).action(
withAuditLogging(
"created",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDuplicateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await duplicateDashboard(parsedInput.dashboardId, projectId, ctx.user.id);
revalidatePath(`/environments/${parsedInput.environmentId}/analysis/dashboards`);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = dashboard.id;
ctx.auditLoggingCtx.newObject = dashboard;
return dashboard;
}
)
);
const ZGetDashboardsAction = z.object({
environmentId: ZId,
});
@@ -0,0 +1,81 @@
"use client";
import { PlusIcon } 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 { Button } from "@/modules/ui/components/button";
import { createDashboardAction } from "../actions";
import { CreateDashboardDialog } from "./create-dashboard-dialog";
interface CreateDashboardButtonProps {
environmentId: string;
}
export const CreateDashboardButton = ({ environmentId }: Readonly<CreateDashboardButtonProps>) => {
const { t } = useTranslation();
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [dashboardName, setDashboardName] = useState("");
const [dashboardDescription, setDashboardDescription] = useState("");
const [isCreating, setIsCreating] = useState(false);
const handleOpenChange = (open: boolean) => {
setIsCreateDialogOpen(open);
if (!open) {
setDashboardName("");
setDashboardDescription("");
}
};
const handleCreate = async () => {
if (!dashboardName.trim()) {
toast.error(t("environments.analysis.dashboards.please_enter_name"));
return;
}
setIsCreating(true);
try {
const result = await createDashboardAction({
environmentId,
name: dashboardName.trim(),
description: dashboardDescription.trim() || undefined,
});
if (!result?.data) {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
return;
}
toast.success(t("environments.analysis.dashboards.create_success"));
handleOpenChange(false);
router.push(`/environments/${environmentId}/analysis/dashboards/${result.data.id}`);
} catch {
toast.error(t("environments.analysis.dashboards.create_failed"));
} finally {
setIsCreating(false);
}
};
return (
<>
<Button onClick={() => handleOpenChange(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("environments.analysis.dashboards.create_dashboard")}
</Button>
<CreateDashboardDialog
open={isCreateDialogOpen}
onOpenChange={handleOpenChange}
dashboardName={dashboardName}
onDashboardNameChange={setDashboardName}
dashboardDescription={dashboardDescription}
onDashboardDescriptionChange={setDashboardDescription}
onCreate={handleCreate}
isCreating={isCreating}
/>
</>
);
};
@@ -0,0 +1,97 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
interface CreateDashboardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
dashboardName: string;
onDashboardNameChange: (name: string) => void;
dashboardDescription: string;
onDashboardDescriptionChange: (description: string) => void;
onCreate: () => void;
isCreating: boolean;
}
export const CreateDashboardDialog = ({
open,
onOpenChange,
dashboardName,
onDashboardNameChange,
dashboardDescription,
onDashboardDescriptionChange,
onCreate,
isCreating,
}: Readonly<CreateDashboardDialogProps>) => {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent width="narrow">
<DialogHeader>
<DialogTitle>{t("environments.analysis.dashboards.create_dashboard")}</DialogTitle>
<DialogDescription>
{t("environments.analysis.dashboards.create_dashboard_description")}
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
if (dashboardName.trim() && !isCreating) {
onCreate();
}
}}
className="space-y-4">
<DialogBody className="space-y-4">
<div className="space-y-2">
<Label htmlFor="dashboard-name">{t("environments.analysis.dashboards.dashboard_name")}</Label>
<Input
id="dashboard-name"
placeholder={t("environments.analysis.dashboards.dashboard_name_placeholder")}
value={dashboardName}
onChange={(e) => onDashboardNameChange(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="dashboard-description">
{t("environments.analysis.dashboards.description_optional")}
</Label>
<Input
id="dashboard-description"
placeholder={t("environments.analysis.dashboards.description_placeholder")}
value={dashboardDescription}
onChange={(e) => onDashboardDescriptionChange(e.target.value)}
/>
</div>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isCreating}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={isCreating} disabled={!dashboardName.trim()}>
{t("common.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,130 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { deleteDashboardAction, duplicateDashboardAction } from "../actions";
interface DashboardDropdownMenuProps {
environmentId: string;
dashboardId: string;
dashboardName: string;
}
export const DashboardDropdownMenu = ({
environmentId,
dashboardId,
dashboardName,
}: Readonly<DashboardDropdownMenuProps>) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const handleDuplicateDashboard = async () => {
setIsDuplicating(true);
try {
const result = await duplicateDashboardAction({ environmentId, dashboardId });
if (result?.data) {
toast.success(t("environments.analysis.dashboards.duplicate_success"));
} else {
toast.error(result?.serverError || t("environments.analysis.dashboards.duplicate_failed"));
}
} catch {
toast.error(t("environments.analysis.dashboards.duplicate_failed"));
} finally {
setIsDuplicating(false);
}
};
const handleDeleteDashboard = async () => {
setIsDeleting(true);
try {
const result = await deleteDashboardAction({ environmentId, dashboardId });
if (result?.data) {
setDeleteDialogOpen(false);
toast.success(t("environments.analysis.dashboards.delete_success"));
} else {
toast.error(result?.serverError || t("environments.analysis.dashboards.delete_failed"));
}
} catch {
toast.error(t("environments.analysis.dashboards.delete_failed"));
} finally {
setIsDeleting(false);
}
};
return (
<div data-testid={`${dashboardName.toLowerCase().split(" ").join("-")}-dashboard-actions`}>
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild>
<button type="button" className="cursor-pointer rounded-lg border bg-white p-2 hover:bg-slate-50">
<span className="sr-only">{t("common.open_options")}</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
<DropdownMenuGroup>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/analysis/dashboards/${dashboardId}`}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
disabled={isDuplicating}
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
handleDuplicateDashboard();
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat={t("common.dashboard")}
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={handleDeleteDashboard}
text={t("environments.analysis.dashboards.delete_confirmation")}
isDeleting={isDeleting}
/>
</div>
);
};
@@ -0,0 +1,49 @@
const SKELETON_ROWS = 3;
const SkeletonRow = () => {
return (
<div className="grid h-12 w-full animate-pulse grid-cols-8 content-center">
<div className="col-span-7 grid grid-cols-7 content-center p-2">
<div className="col-span-3 flex items-center gap-4 pl-6">
<div className="h-5 w-5 rounded bg-gray-200" />
<div className="h-4 w-36 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-6 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-16 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-24 rounded bg-gray-200" />
</div>
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
<div className="h-4 w-20 rounded bg-gray-200" />
</div>
</div>
<div className="col-span-1" />
</div>
);
};
interface DashboardsListSkeletonProps {
columnHeaders: string[];
}
export const DashboardsListSkeleton = ({ columnHeaders }: Readonly<DashboardsListSkeletonProps>) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">{columnHeaders[0]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[1]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[2]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[3]}</div>
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[4]}</div>
<div className="col-span-1" />
</div>
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
<SkeletonRow key={`skeleton-row-${String(i)}`} />
))}
</div>
);
};
@@ -0,0 +1,85 @@
import { BarChart3Icon } from "lucide-react";
import Link from "next/link";
import { convertDateString, timeSinceDate } from "@/lib/time";
import { getTranslate } from "@/lingodotdev/server";
import { TDashboardWithCount } from "../../types/analysis";
import { DashboardDropdownMenu } from "./dashboard-dropdown-menu";
interface DashboardsTableProps {
dashboards: TDashboardWithCount[];
environmentId: string;
isReadOnly: boolean;
}
export const DashboardsTable = async ({
dashboards,
environmentId,
isReadOnly,
}: Readonly<DashboardsTableProps>) => {
const t = await getTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6">{t("common.title")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.charts")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.updated")}</div>
<div className="col-span-1" />
</div>
{dashboards.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">
{t("environments.analysis.dashboards.no_dashboards_found")}
</p>
) : (
dashboards.map((dashboard) => {
return (
<div
key={dashboard.id}
className="grid h-12 w-full grid-cols-8 content-center text-left transition-colors ease-in-out hover:bg-slate-100">
<Link
href={`/environments/${environmentId}/analysis/dashboards/${dashboard.id}`}
className="col-span-7 grid cursor-pointer grid-cols-7 content-center p-2">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="w-8 flex-shrink-0 text-slate-500">
<BarChart3Icon className="h-5 w-5" />
</div>
<div className="flex flex-col">
<div className="font-medium text-slate-900">{dashboard.name}</div>
{dashboard.description && (
<div className="text-xs font-medium text-slate-500">{dashboard.description}</div>
)}
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="text-slate-900">{dashboard._count.widgets}</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="text-slate-900">{dashboard.creator?.name || "-"}</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="text-slate-900">{convertDateString(dashboard.createdAt.toISOString())}</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="text-slate-900">{timeSinceDate(dashboard.updatedAt)}</div>
</div>
</Link>
<div className="col-span-1 my-auto flex items-center justify-end pr-6">
{!isReadOnly && (
<DashboardDropdownMenu
environmentId={environmentId}
dashboardId={dashboard.id}
dashboardName={dashboard.name}
/>
)}
</div>
</div>
);
})
)}
</div>
);
};
@@ -8,6 +8,7 @@ vi.mock("server-only", () => ({}));
var mockTxDashboard: {
// NOSONAR / test code
findFirst: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
@@ -21,7 +22,7 @@ var mockTxWidget: {
};
vi.mock("@formbricks/database", () => {
const txDash = { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() };
const txDash = { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() };
const txChart = { findFirst: vi.fn() };
const txWidget = { aggregate: vi.fn(), create: vi.fn() };
mockTxDashboard = txDash;
@@ -66,6 +67,7 @@ const selectDashboard = {
description: true,
createdAt: true,
updatedAt: true,
createdBy: true,
};
const mockDashboard = {
@@ -74,6 +76,7 @@ const mockDashboard = {
description: "A test dashboard",
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
createdBy: mockUserId,
};
const makePrismaError = (code: string) =>
@@ -256,6 +259,133 @@ describe("Dashboard Service", () => {
});
});
describe("duplicateDashboard", () => {
const mockWidgets = [
{
id: "widget-1",
chartId: mockChartId,
title: "Widget 1",
layout: { x: 0, y: 0, w: 4, h: 3 },
order: 0,
},
{
id: "widget-2",
chartId: "chart-2",
title: null,
layout: { x: 4, y: 0, w: 4, h: 3 },
order: 1,
},
];
const sourceDashboard = {
...mockDashboard,
widgets: mockWidgets,
};
const duplicatedDashboard = {
...mockDashboard,
id: "dashboard-new-123",
name: "Test Dashboard (copy)",
};
test("duplicates a dashboard with all widgets", async () => {
mockTxDashboard.findFirst.mockResolvedValueOnce(sourceDashboard).mockResolvedValueOnce(null);
mockTxDashboard.create.mockResolvedValue(duplicatedDashboard);
const { duplicateDashboard } = await import("./dashboards");
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
expect(result).toEqual(duplicatedDashboard);
expect(mockTxDashboard.create).toHaveBeenCalledWith({
data: {
name: "Test Dashboard (copy)",
description: mockDashboard.description,
projectId: mockProjectId,
createdBy: mockUserId,
widgets: {
create: [
{
chartId: mockChartId,
title: "Widget 1",
layout: { x: 0, y: 0, w: 4, h: 3 },
order: 0,
},
{
chartId: "chart-2",
title: null,
layout: { x: 4, y: 0, w: 4, h: 3 },
order: 1,
},
],
},
},
select: selectDashboard,
});
});
test("duplicates a dashboard with no widgets", async () => {
const sourceNoWidgets = { ...mockDashboard, widgets: [] };
mockTxDashboard.findFirst.mockResolvedValueOnce(sourceNoWidgets).mockResolvedValueOnce(null);
mockTxDashboard.create.mockResolvedValue(duplicatedDashboard);
const { duplicateDashboard } = await import("./dashboards");
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
expect(result).toEqual(duplicatedDashboard);
expect(mockTxDashboard.create).toHaveBeenCalledWith({
data: expect.objectContaining({
widgets: { create: [] },
}),
select: selectDashboard,
});
});
test("increments copy suffix when name already exists", async () => {
const existingCopy = { id: "existing", name: "Test Dashboard (copy)" };
mockTxDashboard.findFirst
.mockResolvedValueOnce(sourceDashboard)
.mockResolvedValueOnce(existingCopy)
.mockResolvedValueOnce(null);
mockTxDashboard.create.mockResolvedValue({
...duplicatedDashboard,
name: "Test Dashboard (copy) 2",
});
const { duplicateDashboard } = await import("./dashboards");
const result = await duplicateDashboard(mockDashboardId, mockProjectId, mockUserId);
expect(result.name).toBe("Test Dashboard (copy) 2");
expect(mockTxDashboard.create).toHaveBeenCalledWith({
data: expect.objectContaining({ name: "Test Dashboard (copy) 2" }),
select: selectDashboard,
});
});
test("throws ResourceNotFoundError when source dashboard does not exist", async () => {
mockTxDashboard.findFirst.mockResolvedValue(null);
const { duplicateDashboard } = await import("./dashboards");
await expect(duplicateDashboard(mockDashboardId, mockProjectId, mockUserId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
expect(mockTxDashboard.create).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma errors", async () => {
mockTxDashboard.findFirst.mockRejectedValue(makePrismaError("P9999"));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
);
const { duplicateDashboard } = await import("./dashboards");
await expect(duplicateDashboard(mockDashboardId, mockProjectId, mockUserId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getDashboard", () => {
test("returns a dashboard with widgets", async () => {
const dashboardWithWidgets = {
@@ -311,10 +441,10 @@ describe("Dashboard Service", () => {
});
describe("getDashboards", () => {
test("returns all dashboards for a project", async () => {
test("returns all dashboards for a project with creator", async () => {
const dashboards = [
{ ...mockDashboard, _count: { widgets: 3 } },
{ ...mockDashboard, id: "dash-2", name: "Dashboard 2", _count: { widgets: 0 } },
{ ...mockDashboard, creator: { name: "Alice" }, _count: { widgets: 3 } },
{ ...mockDashboard, id: "dash-2", name: "Dashboard 2", creator: null, _count: { widgets: 0 } },
];
vi.mocked(prisma.dashboard.findMany).mockResolvedValue(dashboards as any);
const { getDashboards } = await import("./dashboards");
@@ -328,6 +458,7 @@ describe("Dashboard Service", () => {
select: expect.objectContaining({
id: true,
name: true,
creator: { select: { name: true } },
_count: { select: { widgets: true } },
}),
});
@@ -17,12 +17,15 @@ import {
ZDashboardUpdateInput,
} from "@/modules/ee/analysis/types/analysis";
const MAX_NAME_ATTEMPTS = 5;
const selectDashboard = {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
createdBy: true,
} as const;
export const createDashboard = async (data: TDashboardCreateInput): Promise<TDashboard> => {
@@ -166,6 +169,7 @@ export const getDashboards = async (projectId: string): Promise<TDashboardWithCo
orderBy: { createdAt: "desc" },
select: {
...selectDashboard,
creator: { select: { name: true } },
_count: { select: { widgets: true } },
},
});
@@ -177,6 +181,70 @@ export const getDashboards = async (projectId: string): Promise<TDashboardWithCo
}
};
export const duplicateDashboard = async (
dashboardId: string,
projectId: string,
createdBy: string
): Promise<TDashboard> => {
validateInputs([dashboardId, ZId], [projectId, ZId], [createdBy, ZId]);
try {
return await prisma.$transaction(async (tx) => {
const source = await tx.dashboard.findFirst({
where: { id: dashboardId, projectId },
include: {
widgets: { orderBy: { order: "asc" } },
},
});
if (!source) {
throw new ResourceNotFoundError("Dashboard", dashboardId);
}
const baseName = `${source.name} (copy)`;
let name = baseName;
let suffix = 1;
while (await tx.dashboard.findFirst({ where: { projectId, name } })) {
suffix++;
if (suffix > MAX_NAME_ATTEMPTS) {
name = `${baseName} ${suffix}`;
break;
}
name = `${baseName} ${suffix}`;
}
const newDashboard = await tx.dashboard.create({
data: {
name,
description: source.description,
projectId,
createdBy,
widgets: {
create: source.widgets.map((widget) => ({
chartId: widget.chartId,
title: widget.title,
layout: widget.layout ?? { x: 0, y: 0, w: 4, h: 3 },
order: widget.order,
})),
},
},
select: selectDashboard,
});
return newDashboard;
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const addChartToDashboard = async (data: TAddWidgetInput) => {
validateInputs([data, ZAddWidgetInput]);
@@ -0,0 +1,62 @@
import { Delay } from "@suspensive/react";
import { Suspense, use } from "react";
import { getTranslate } from "@/lingodotdev/server";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TDashboardWithCount } from "../../types/analysis";
import { CreateDashboardButton } from "../components/create-dashboard-button";
import { DashboardsListSkeleton } from "../components/dashboards-list-skeleton";
import { DashboardsTable } from "../components/dashboards-table";
import { getDashboards } from "../lib/dashboards";
interface DashboardsListContentProps {
dashboardsPromise: Promise<TDashboardWithCount[]>;
environmentId: string;
isReadOnly: boolean;
}
const DashboardsListContent = ({
dashboardsPromise,
environmentId,
isReadOnly,
}: Readonly<DashboardsListContentProps>) => {
const dashboards = use(dashboardsPromise);
return <DashboardsTable dashboards={dashboards} environmentId={environmentId} isReadOnly={isReadOnly} />;
};
export const DashboardsListPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const { environmentId } = await props.params;
const t = await getTranslate();
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
const dashboardsPromise = getDashboards(project.id);
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
environmentId={environmentId}
cta={isReadOnly ? undefined : <CreateDashboardButton environmentId={environmentId} />}>
<Suspense
fallback={
<Delay ms={200}>
<DashboardsListSkeleton
columnHeaders={[
t("common.title"),
t("common.charts"),
t("common.created_by"),
t("common.created"),
t("common.updated"),
]}
/>
</Delay>
}>
<DashboardsListContent
dashboardsPromise={dashboardsPromise}
environmentId={environmentId}
isReadOnly={isReadOnly}
/>
</Suspense>
</AnalysisPageLayout>
);
};
@@ -65,9 +65,11 @@ export type TDashboard = {
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string | null;
};
export type TDashboardWithCount = TDashboard & {
creator: { name: string } | null;
_count: { widgets: number };
};
+6 -4
View File
@@ -55,9 +55,9 @@
"@opentelemetry/sdk-node": "0.211.0",
"@opentelemetry/sdk-trace-base": "2.5.0",
"@opentelemetry/semantic-conventions": "1.38.0",
"@prisma/instrumentation": "6.14.0",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.14.0",
"@prisma/instrumentation": "6.14.0",
"@radix-ui/react-accordion": "1.2.10",
"@radix-ui/react-checkbox": "1.3.1",
"@radix-ui/react-collapsible": "1.1.10",
@@ -76,6 +76,7 @@
"@radix-ui/react-toggle-group": "1.1.9",
"@radix-ui/react-tooltip": "1.2.6",
"@sentry/nextjs": "10.5.0",
"@suspensive/react": "3.19.0",
"@t3-oss/env-nextjs": "0.13.4",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
@@ -115,10 +116,12 @@
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",
"react": "19.2.3",
"react-calendar": "5.1.0",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
"react-day-picker": "9.6.7",
"react-dom": "19.2.3",
"react-hook-form": "7.56.2",
"react-hot-toast": "2.5.2",
"react-i18next": "15.7.3",
@@ -137,9 +140,7 @@
"webpack": "5.99.8",
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "3.24.4",
"zod-openapi": "4.2.4",
"react": "19.2.3",
"react-dom": "19.2.3"
"zod-openapi": "4.2.4"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
@@ -165,6 +166,7 @@
"esbuild": "0.25.12",
"postcss": "8.5.3",
"resize-observer-polyfill": "1.5.1",
"tailwindcss-animate": "1.0.7",
"ts-node": "10.9.2",
"vite": "6.4.1",
"vite-tsconfig-paths": "5.1.4",
+5 -1
View File
@@ -154,5 +154,9 @@ module.exports = {
},
safelist: [{ pattern: /max-w-./, variants: "sm" }],
darkMode: "class", // Set dark mode to use the 'class' strategy
plugins: [require("@tailwindcss/forms"), require("@tailwindcss/typography")],
plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/forms"),
require("@tailwindcss/typography"),
],
};
+24
View File
@@ -298,6 +298,9 @@ importers:
'@sentry/nextjs':
specifier: 10.5.0
version: 10.5.0(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.1.6(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.99.8(esbuild@0.25.12))
'@suspensive/react':
specifier: 3.19.0
version: 3.19.0(react@19.2.3)
'@t3-oss/env-nextjs':
specifier: 0.13.4
version: 0.13.4(arktype@2.1.29)(typescript@5.8.3)(zod@3.24.4)
@@ -560,6 +563,9 @@ importers:
resize-observer-polyfill:
specifier: 1.5.1
version: 1.5.1
tailwindcss-animate:
specifier: 1.0.7
version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.8.3)))
ts-node:
specifier: 10.9.2
version: 10.9.2(@types/node@22.15.18)(typescript@5.8.3)
@@ -5486,6 +5492,11 @@ packages:
'@streamparser/json@0.0.20':
resolution: {integrity: sha512-VqAAkydywPpkw63WQhPVKCD3SdwXuihCUVZbbiY3SfSTGQyHmwRoq27y4dmJdZuJwd5JIlQoMPyGvMbUPY0RKQ==}
'@suspensive/react@3.19.0':
resolution: {integrity: sha512-EzR7KuJj0k8MtS+COcrt7aANh4MLUlA2B1mIZVlPUdUuhFnbU8AvvmG5Knl2e6JFttpZBoB0keUovQxg99EKHA==}
peerDependencies:
react: ^18 || ^19
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -10868,6 +10879,11 @@ packages:
tailwind-merge@3.2.0:
resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==}
tailwindcss-animate@1.0.7:
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders'
tailwindcss@3.4.17:
resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==}
engines: {node: '>=14.0.0'}
@@ -17953,6 +17969,10 @@ snapshots:
'@streamparser/json@0.0.20': {}
'@suspensive/react@3.19.0(react@19.2.3)':
dependencies:
react: 19.2.3
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -24073,6 +24093,10 @@ snapshots:
tailwind-merge@3.2.0: {}
tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.8.3))):
dependencies:
tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.8.3))
tailwindcss@3.4.17(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.8.3)):
dependencies:
'@alloc/quick-lru': 5.2.0