mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-21 21:18:50 -05:00
feat: (dashboards) listing page (#7330)
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "うまくいきました!接続されました。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Отлично! Мы подключены.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "做得好 !我们 已经 连接。",
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
};
|
||||
|
||||
Generated
+24
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user