feat: Charts list page with demo create/edit and real delete/duplicate (#7353)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
This commit is contained in:
Dhruwang Jariwala
2026-02-25 16:50:34 +05:30
committed by GitHub
parent fbbf917093
commit 3a802810e3
31 changed files with 719 additions and 13 deletions
@@ -1,9 +1,8 @@
const ChartsPage = () => {
return (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
Charts will appear here.
</div>
);
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
const ChartsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return <ChartsListPage environmentId={environmentId} />;
};
export default ChartsPage;
@@ -1,4 +1,4 @@
const DashboardDetailPage = async (props: { params: Promise<{ dashboardId: string }> }) => {
const DashboardDetailPage = async (props: Readonly<{ params: Promise<{ dashboardId: string }> }>) => {
const { dashboardId } = await props.params;
return (
@@ -1 +1,8 @@
export { DashboardsListPage as default } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
const DashboardsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return <DashboardsListPage environmentId={environmentId} />;
};
export default DashboardsPage;
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
const AnalysisPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const AnalysisPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
const { environmentId } = await props.params;
return redirect(`/environments/${environmentId}/analysis/dashboards`);
};
+16
View File
@@ -123,6 +123,7 @@ checksums:
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
common/cancel: 2e2a849c2223911717de8caa2c71bade
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
common/charts: 1da4564d89264c89de4ed28d7451b43e
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
@@ -203,6 +204,7 @@ checksums:
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/first_name: cf040a5d6a9fd696be400380cc99f54b
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
@@ -585,6 +587,20 @@ 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/charts/action_coming_soon: ee2b0671e00972773210c5be5a9ccb89
environments/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e
environments/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0
environments/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165
environments/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4
environments/analysis/charts/chart_type_area: 535754c6425f045f17e1dcb551840c93
environments/analysis/charts/chart_type_bar: c11d460595d3ddfe8efd67ac068574c5
environments/analysis/charts/chart_type_big_number: 9d17fb96241507c955dca25e143ae67a
environments/analysis/charts/chart_type_line: f42dd53238ed4d44def306a61d47d5c4
environments/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c
environments/analysis/charts/create_chart: ca7fdcc964e01f42ea9709924221edba
environments/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
environments/analysis/charts/no_charts_found: d4a27d5b56e49ebdd38bf28791dbcc42
environments/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
environments/analysis/dashboards/create_dashboard: 9396aec1ea4a9b05ada94483655d1373
environments/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
environments/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "Unten rechts",
"cancel": "Abbrechen",
"centered_modal": "Zentriertes Modalfenster",
"chart": "Diagramm",
"charts": "Diagramme",
"choices": "Entscheidungen",
"choose_environment": "Umgebung auswählen",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "Ihre Umfrage wäre nicht angezeigt."
},
"analysis": {
"charts": {
"action_coming_soon": "Kommt bald",
"chart_deleted_successfully": "Diagramm erfolgreich gelöscht",
"chart_deletion_error": "Diagramm konnte nicht gelöscht werden",
"chart_duplicated_successfully": "Diagramm erfolgreich dupliziert",
"chart_duplication_error": "Diagramm konnte nicht dupliziert werden",
"chart_type_area": "Flächendiagramm",
"chart_type_bar": "Balkendiagramm",
"chart_type_big_number": "Große Zahl",
"chart_type_line": "Liniendiagramm",
"chart_type_pie": "Kreisdiagramm",
"create_chart": "Diagramm erstellen",
"delete_chart_confirmation": "Bist du sicher, dass du dieses Diagramm löschen möchtest?",
"no_charts_found": "Keine Diagramme gefunden.",
"open_options": "Diagrammoptionen öffnen"
},
"dashboards": {
"create_dashboard": "Dashboard erstellen",
"create_dashboard_description": "Gib einen Namen für dein neues Dashboard ein.",
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "Bottom Right",
"cancel": "Cancel",
"centered_modal": "Centered Modal",
"chart": "Chart",
"charts": "Charts",
"choices": "Choices",
"choose_environment": "Choose environment",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "Your survey would not be shown."
},
"analysis": {
"charts": {
"action_coming_soon": "Coming soon",
"chart_deleted_successfully": "Chart deleted successfully",
"chart_deletion_error": "Failed to delete chart",
"chart_duplicated_successfully": "Chart duplicated successfully",
"chart_duplication_error": "Failed to duplicate chart",
"chart_type_area": "Area Chart",
"chart_type_bar": "Bar Chart",
"chart_type_big_number": "Big Number",
"chart_type_line": "Line Chart",
"chart_type_pie": "Pie Chart",
"create_chart": "Create Chart",
"delete_chart_confirmation": "Are you sure you want to delete this chart?",
"no_charts_found": "No charts found.",
"open_options": "Open chart options"
},
"dashboards": {
"create_dashboard": "Create Dashboard",
"create_dashboard_description": "Enter a name for your new dashboard.",
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "Inferior derecha",
"cancel": "Cancelar",
"centered_modal": "Modal centrado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Opciones",
"choose_environment": "Elegir entorno",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "Tu encuesta no se mostraría."
},
"analysis": {
"charts": {
"action_coming_soon": "Próximamente",
"chart_deleted_successfully": "Gráfico eliminado correctamente",
"chart_deletion_error": "Error al eliminar el gráfico",
"chart_duplicated_successfully": "Gráfico duplicado correctamente",
"chart_duplication_error": "Error al duplicar el gráfico",
"chart_type_area": "Gráfico de área",
"chart_type_bar": "Gráfico de barras",
"chart_type_big_number": "Número grande",
"chart_type_line": "Gráfico de líneas",
"chart_type_pie": "Gráfico circular",
"create_chart": "Crear gráfico",
"delete_chart_confirmation": "¿Estás seguro de que quieres eliminar este gráfico?",
"no_charts_found": "No se encontraron gráficos.",
"open_options": "Abrir opciones del gráfico"
},
"dashboards": {
"create_dashboard": "Crear panel de control",
"create_dashboard_description": "Introduce un nombre para tu panel de control nuevo.",
+17 -1
View File
@@ -112,7 +112,6 @@
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
},
"common": {
"Filter": "Filtrer",
"accepted": "Accepté",
"account": "Compte",
"account_settings": "Paramètres du compte",
@@ -151,6 +150,7 @@
"bottom_right": "En bas à droite",
"cancel": "Annuler",
"centered_modal": "Au centre",
"chart": "Graphique",
"charts": "Graphiques",
"choices": "Choix",
"choose_environment": "Choisir l'environnement",
@@ -621,6 +621,22 @@
"your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée."
},
"analysis": {
"charts": {
"action_coming_soon": "À venir bientôt",
"chart_deleted_successfully": "Graphique supprimé avec succès",
"chart_deletion_error": "Échec de la suppression du graphique",
"chart_duplicated_successfully": "Graphique dupliqué avec succès",
"chart_duplication_error": "Échec de la duplication du graphique",
"chart_type_area": "Graphique en aires",
"chart_type_bar": "Graphique à barres",
"chart_type_big_number": "Grand nombre",
"chart_type_line": "Graphique linéaire",
"chart_type_pie": "Graphique circulaire",
"create_chart": "Créer un graphique",
"delete_chart_confirmation": "Êtes-vous sûr de vouloir supprimer ce graphique?",
"no_charts_found": "Aucun graphique trouvé.",
"open_options": "Ouvrir les options du graphique"
},
"dashboards": {
"create_dashboard": "Créer un tableau de bord",
"create_dashboard_description": "Saisissez un nom pour votre nouveau tableau de bord.",
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "Jobbra lent",
"cancel": "Mégse",
"centered_modal": "Középre helyezett kizárólagos",
"chart": "Diagram",
"charts": "Diagramok",
"choices": "Választási lehetőségek",
"choose_environment": "Környezet kiválasztása",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "A kérdőív nem jelenne meg."
},
"analysis": {
"charts": {
"action_coming_soon": "Hamarosan",
"chart_deleted_successfully": "A diagram sikeresen törölve",
"chart_deletion_error": "A diagram törlése sikertelen",
"chart_duplicated_successfully": "A diagram sikeresen duplikálva",
"chart_duplication_error": "A diagram duplikálása sikertelen",
"chart_type_area": "Területdiagram",
"chart_type_bar": "Oszlopdiagram",
"chart_type_big_number": "Nagy szám",
"chart_type_line": "Vonaldiagram",
"chart_type_pie": "Kördiagram",
"create_chart": "Diagram létrehozása",
"delete_chart_confirmation": "Biztosan törölni szeretnéd ezt a diagramot?",
"no_charts_found": "Nem található diagram.",
"open_options": "Diagram beállításainak megnyitása"
},
"dashboards": {
"create_dashboard": "Vezérlőpult létrehozása",
"create_dashboard_description": "Adjon nevet az új vezérlőpultnak.",
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "右下",
"cancel": "キャンセル",
"centered_modal": "中央モーダル",
"chart": "チャート",
"charts": "チャート",
"choices": "選択肢",
"choose_environment": "環境を選択",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "あなたのフォームは表示されません。"
},
"analysis": {
"charts": {
"action_coming_soon": "近日公開",
"chart_deleted_successfully": "チャートを削除しました",
"chart_deletion_error": "チャートの削除に失敗しました",
"chart_duplicated_successfully": "チャートを複製しました",
"chart_duplication_error": "チャートの複製に失敗しました",
"chart_type_area": "エリアチャート",
"chart_type_bar": "棒グラフ",
"chart_type_big_number": "大きな数値",
"chart_type_line": "折れ線グラフ",
"chart_type_pie": "円グラフ",
"create_chart": "チャートを作成",
"delete_chart_confirmation": "このチャートを削除してもよろしいですか?",
"no_charts_found": "チャートが見つかりません。",
"open_options": "チャートオプションを開く"
},
"dashboards": {
"create_dashboard": "ダッシュボードを作成",
"create_dashboard_description": "新しいダッシュボードの名前を入力してください。",
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "Rechtsonder",
"cancel": "Annuleren",
"centered_modal": "Gecentreerd modaal",
"chart": "Grafiek",
"charts": "Grafieken",
"choices": "Keuzes",
"choose_environment": "Kies omgeving",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "Uw enquête wordt niet getoond."
},
"analysis": {
"charts": {
"action_coming_soon": "Binnenkort beschikbaar",
"chart_deleted_successfully": "Grafiek succesvol verwijderd",
"chart_deletion_error": "Verwijderen van grafiek mislukt",
"chart_duplicated_successfully": "Grafiek succesvol gedupliceerd",
"chart_duplication_error": "Dupliceren van grafiek mislukt",
"chart_type_area": "Vlakdiagram",
"chart_type_bar": "Staafdiagram",
"chart_type_big_number": "Groot getal",
"chart_type_line": "Lijndiagram",
"chart_type_pie": "Cirkeldiagram",
"create_chart": "Diagram maken",
"delete_chart_confirmation": "Weet je zeker dat je deze grafiek wilt verwijderen?",
"no_charts_found": "Geen diagrammen gevonden.",
"open_options": "Open diagramopties"
},
"dashboards": {
"create_dashboard": "Dashboard creëren",
"create_dashboard_description": "Voer een naam in voor je nieuwe dashboard.",
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "Canto Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Escolhas",
"choose_environment": "Escolher ambiente",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "Sua pesquisa não seria exibida."
},
"analysis": {
"charts": {
"action_coming_soon": "Em breve",
"chart_deleted_successfully": "Gráfico excluído com sucesso",
"chart_deletion_error": "Falha ao excluir gráfico",
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
"chart_duplication_error": "Falha ao duplicar gráfico",
"chart_type_area": "Gráfico de área",
"chart_type_bar": "Gráfico de barras",
"chart_type_big_number": "Número grande",
"chart_type_line": "Gráfico de linhas",
"chart_type_pie": "Gráfico de pizza",
"create_chart": "Criar gráfico",
"delete_chart_confirmation": "Tem certeza de que deseja excluir este gráfico?",
"no_charts_found": "Nenhum gráfico encontrado.",
"open_options": "Abrir opções do gráfico"
},
"dashboards": {
"create_dashboard": "Criar painel",
"create_dashboard_description": "Digite um nome para o seu novo painel.",
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Escolhas",
"choose_environment": "Escolha o ambiente",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "O seu inquérito não seria mostrado."
},
"analysis": {
"charts": {
"action_coming_soon": "Em breve",
"chart_deleted_successfully": "Gráfico eliminado com sucesso",
"chart_deletion_error": "Falha ao eliminar gráfico",
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
"chart_duplication_error": "Falha ao duplicar gráfico",
"chart_type_area": "Gráfico de área",
"chart_type_bar": "Gráfico de barras",
"chart_type_big_number": "Número grande",
"chart_type_line": "Gráfico de linhas",
"chart_type_pie": "Gráfico circular",
"create_chart": "Criar gráfico",
"delete_chart_confirmation": "Tens a certeza de que queres eliminar este gráfico?",
"no_charts_found": "Nenhum gráfico encontrado.",
"open_options": "Abrir opções do gráfico"
},
"dashboards": {
"create_dashboard": "Criar painel",
"create_dashboard_description": "Introduza um nome para o seu novo painel.",
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "Dreapta Jos",
"cancel": "Anulare",
"centered_modal": "Modală centralizată",
"chart": "Grafic",
"charts": "Grafice",
"choices": "Alegeri",
"choose_environment": "Alege mediul",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "Sondajul dumneavoastră nu va fi afișat."
},
"analysis": {
"charts": {
"action_coming_soon": "În curând",
"chart_deleted_successfully": "Graficul a fost șters cu succes",
"chart_deletion_error": "Nu s-a putut șterge graficul",
"chart_duplicated_successfully": "Graficul a fost duplicat cu succes",
"chart_duplication_error": "Nu s-a putut duplica graficul",
"chart_type_area": "Grafic de tip arie",
"chart_type_bar": "Grafic de tip bară",
"chart_type_big_number": "Număr mare",
"chart_type_line": "Grafic de tip linie",
"chart_type_pie": "Grafic de tip plăcintă",
"create_chart": "Creează grafic",
"delete_chart_confirmation": "Ești sigur că vrei să ștergi acest grafic?",
"no_charts_found": "Nu s-au găsit grafice.",
"open_options": "Deschide opțiunile graficului"
},
"dashboards": {
"create_dashboard": "Creează tablou de bord",
"create_dashboard_description": "Introdu un nume pentru noul tău tablou de bord.",
+17 -1
View File
@@ -112,7 +112,6 @@
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна."
},
"common": {
"Filter": "Фильтр",
"accepted": "Принято",
"account": "Аккаунт",
"account_settings": "Настройки аккаунта",
@@ -151,6 +150,7 @@
"bottom_right": "Внизу справа",
"cancel": "Отмена",
"centered_modal": "Центрированное модальное окно",
"chart": "График",
"charts": "Графики",
"choices": "Варианты",
"choose_environment": "Выберите среду",
@@ -621,6 +621,22 @@
"your_survey_would_not_be_shown": "Ваш опрос не будет отображаться."
},
"analysis": {
"charts": {
"action_coming_soon": "Скоро будет",
"chart_deleted_successfully": "График успешно удалён",
"chart_deletion_error": "Не удалось удалить график",
"chart_duplicated_successfully": "График успешно дублирован",
"chart_duplication_error": "Не удалось дублировать график",
"chart_type_area": "График областью",
"chart_type_bar": "Столбчатая диаграмма",
"chart_type_big_number": "Большое число",
"chart_type_line": "Линейный график",
"chart_type_pie": "Круговая диаграмма",
"create_chart": "Создать график",
"delete_chart_confirmation": "Ты уверен, что хочешь удалить этот график?",
"no_charts_found": "Графики не найдены.",
"open_options": "Открыть настройки графика"
},
"dashboards": {
"create_dashboard": "Создать панель управления",
"create_dashboard_description": "Введите название для новой панели управления.",
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "Nedre höger",
"cancel": "Avbryt",
"centered_modal": "Centrerad modal",
"chart": "Diagram",
"charts": "Diagram",
"choices": "Val",
"choose_environment": "Välj miljö",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "Din enkät skulle inte visas."
},
"analysis": {
"charts": {
"action_coming_soon": "Kommer snart",
"chart_deleted_successfully": "Diagrammet har tagits bort",
"chart_deletion_error": "Det gick inte att ta bort diagrammet",
"chart_duplicated_successfully": "Diagrammet har duplicerats",
"chart_duplication_error": "Det gick inte att duplicera diagrammet",
"chart_type_area": "Ytdiagram",
"chart_type_bar": "Stapeldiagram",
"chart_type_big_number": "Stort tal",
"chart_type_line": "Linjediagram",
"chart_type_pie": "Cirkeldiagram",
"create_chart": "Skapa diagram",
"delete_chart_confirmation": "Är du säker på att du vill ta bort det här diagrammet?",
"no_charts_found": "Inga diagram hittades.",
"open_options": "Öppna diagramalternativ"
},
"dashboards": {
"create_dashboard": "Skapa instrumentpanel",
"create_dashboard_description": "Ange ett namn för din nya instrumentpanel.",
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "居中 模态",
"chart": "图表",
"charts": "图表",
"choices": "选项",
"choose_environment": "选择 环境",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "您的 调查 不会 显示。"
},
"analysis": {
"charts": {
"action_coming_soon": "即将推出",
"chart_deleted_successfully": "图表删除成功",
"chart_deletion_error": "图表删除失败",
"chart_duplicated_successfully": "图表复制成功",
"chart_duplication_error": "图表复制失败",
"chart_type_area": "面积图",
"chart_type_bar": "柱状图",
"chart_type_big_number": "大数字",
"chart_type_line": "折线图",
"chart_type_pie": "饼图",
"create_chart": "创建图表",
"delete_chart_confirmation": "你确定要删除这个图表吗?",
"no_charts_found": "未找到图表。",
"open_options": "打开图表选项"
},
"dashboards": {
"create_dashboard": "创建 Dashboard",
"create_dashboard_description": "请输入新 Dashboard 的名称。",
+17
View File
@@ -150,6 +150,7 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "置中彈窗",
"chart": "圖表",
"charts": "圖表",
"choices": "選項",
"choose_environment": "選擇環境",
@@ -620,6 +621,22 @@
"your_survey_would_not_be_shown": "您的問卷將不會顯示。"
},
"analysis": {
"charts": {
"action_coming_soon": "即將推出",
"chart_deleted_successfully": "圖表已成功刪除",
"chart_deletion_error": "刪除圖表失敗",
"chart_duplicated_successfully": "圖表已成功複製",
"chart_duplication_error": "圖表複製失敗",
"chart_type_area": "區域圖",
"chart_type_bar": "長條圖",
"chart_type_big_number": "大數字",
"chart_type_line": "折線圖",
"chart_type_pie": "圓餅圖",
"create_chart": "建立圖表",
"delete_chart_confirmation": "你確定要刪除此圖表嗎?",
"no_charts_found": "找不到圖表。",
"open_options": "開啟圖表選項"
},
"dashboards": {
"create_dashboard": "建立儀表板",
"create_dashboard_description": "請輸入新儀表板的名稱。",
@@ -0,0 +1,125 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } 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 { deleteChartAction, duplicateChartAction } from "@/modules/ee/analysis/charts/actions";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
interface ChartDropdownMenuProps {
environmentId: string;
chart: TChartWithCreator;
}
export function ChartDropdownMenu({ environmentId, chart }: Readonly<ChartDropdownMenuProps>) {
const { t } = useTranslation();
const router = useRouter();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const handleDuplicate = async () => {
setIsDuplicating(true);
try {
const result = await duplicateChartAction({ environmentId, chartId: chart.id });
if (result?.data) {
toast.success(t("environments.analysis.charts.chart_duplicated_successfully"));
router.refresh();
} else {
toast.error(
getFormattedErrorMessage(result) || t("environments.analysis.charts.chart_duplication_error")
);
}
} catch {
toast.error(t("environments.analysis.charts.chart_duplication_error"));
} finally {
setIsDuplicating(false);
}
};
const handleDelete = async () => {
setIsDeleting(true);
try {
const result = await deleteChartAction({ environmentId, chartId: chart.id });
if (result?.data) {
toast.success(t("environments.analysis.charts.chart_deleted_successfully"));
setDeleteDialogOpen(false);
router.refresh();
} else {
const msg =
getFormattedErrorMessage(result) || t("environments.analysis.charts.chart_deletion_error");
toast.error(msg);
}
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDeleting(false);
}
};
const handleEdit = () => {
toast(t("environments.analysis.charts.action_coming_soon"));
};
return (
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild>
<Button variant="outline" className="px-2" onClick={(e) => e.stopPropagation()}>
<span className="sr-only">{t("environments.analysis.charts.open_options")}</span>
<MoreVertical className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max" align="end">
<DropdownMenuGroup>
<DropdownMenuItem icon={<SquarePenIcon className="size-4" />} onClick={handleEdit}>
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem
icon={<CopyIcon className="size-4" />}
onClick={() => {
setIsDropDownOpen(false);
handleDuplicate();
}}
disabled={isDuplicating}>
{t("common.duplicate")}
</DropdownMenuItem>
<DropdownMenuItem
icon={<TrashIcon className="size-4" />}
onClick={() => {
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
}}
disabled={isDeleting}>
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat={t("common.chart")}
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={handleDelete}
text={t("environments.analysis.charts.delete_chart_confirmation")}
isDeleting={isDeleting}
/>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
import { BarChart3Icon } from "lucide-react";
import { convertDateString, timeSinceDate } from "@/lib/time";
import { ChartDropdownMenu } from "@/modules/ee/analysis/charts/components/chart-dropdown-menu";
import { CHART_TYPE_ICONS } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
interface ChartRowProps {
chart: TChartWithCreator;
environmentId: string;
isReadOnly: boolean;
}
export function ChartRow({ chart, environmentId, isReadOnly }: Readonly<ChartRowProps>) {
const IconComponent = CHART_TYPE_ICONS[chart.type as keyof typeof CHART_TYPE_ICONS] ?? BarChart3Icon;
return (
<div className="grid h-12 w-full grid-cols-7 content-center text-left transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-6 grid grid-cols-6 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="ph-no-capture w-8 flex-shrink-0 text-slate-500">
<IconComponent className="h-5 w-5" />
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</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="ph-no-capture text-slate-900">{chart.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="ph-no-capture text-slate-900">
{convertDateString(chart.createdAt.toISOString())}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{timeSinceDate(new Date(chart.updatedAt))}</div>
</div>
</div>
<div className="col-span-1 my-auto flex items-center justify-end pr-6">
{!isReadOnly && <ChartDropdownMenu environmentId={environmentId} chart={chart} />}
</div>
</div>
);
}
@@ -0,0 +1,63 @@
import { Delay } from "@suspensive/react";
import { Suspense, use } from "react";
import { getTranslate } from "@/lingodotdev/server";
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
import { ChartsListSkeleton } from "@/modules/ee/analysis/charts/components/charts-list-skeleton";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
interface ChartsListContentProps {
chartsPromise: Promise<TChartWithCreator[]>;
environmentId: string;
isReadOnly: boolean;
}
const ChartsListContent = ({
chartsPromise,
environmentId,
isReadOnly,
}: Readonly<ChartsListContentProps>) => {
const charts = use(chartsPromise);
return <ChartsList charts={charts} environmentId={environmentId} isReadOnly={isReadOnly} />;
};
interface ChartsListPageProps {
environmentId: string;
}
export async function ChartsListPage({ environmentId }: Readonly<ChartsListPageProps>) {
const t = await getTranslate();
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
const chartsPromise = getChartsWithCreator(project.id);
return (
<AnalysisPageLayout
pageTitle={t("common.analysis")}
environmentId={environmentId}
cta={isReadOnly ? undefined : <CreateChartButton />}>
<Suspense
fallback={
<Delay ms={200}>
<ChartsListSkeleton
columnHeaders={[
t("common.title"),
t("common.created_by"),
t("common.created_at"),
t("common.updated_at"),
]}
/>
</Delay>
}>
<ChartsListContent
chartsPromise={chartsPromise}
environmentId={environmentId}
isReadOnly={isReadOnly}
/>
</Suspense>
</AnalysisPageLayout>
);
}
@@ -0,0 +1,43 @@
const SKELETON_ROWS = 3;
const SkeletonRow = () => {
return (
<div className="grid h-12 w-full animate-pulse 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-20 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 className="col-span-1" />
</div>
);
};
interface ChartsListSkeletonProps {
columnHeaders: [string, string, string, string];
}
export const ChartsListSkeleton = ({ columnHeaders }: Readonly<ChartsListSkeletonProps>) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 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" />
</div>
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
<SkeletonRow key={`skeleton-row-${String(i)}`} />
))}
</div>
);
};
@@ -0,0 +1,34 @@
import { getTranslate } from "@/lingodotdev/server";
import { ChartRow } from "@/modules/ee/analysis/charts/components/chart-row";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
interface ChartsListProps {
charts: TChartWithCreator[];
environmentId: string;
isReadOnly: boolean;
}
export const ChartsList = async ({ charts, environmentId, isReadOnly }: Readonly<ChartsListProps>) => {
const t = await getTranslate();
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-7 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.created_by")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
<div className="col-span-1" />
</div>
{charts.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">
{t("environments.analysis.charts.no_charts_found")}
</p>
) : (
charts.map((chart) => (
<ChartRow key={chart.id} chart={chart} environmentId={environmentId} isReadOnly={isReadOnly} />
))
)}
</div>
);
};
@@ -0,0 +1,21 @@
"use client";
import { PlusIcon } from "lucide-react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
export function CreateChartButton() {
const { t } = useTranslation();
const handleClick = () => {
toast(t("environments.analysis.charts.action_coming_soon"));
};
return (
<Button onClick={handleClick}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("environments.analysis.charts.create_chart")}
</Button>
);
}
@@ -0,0 +1,18 @@
import { describe, expect, test, vi } from "vitest";
import { CHART_TYPE_ICONS, getChartTypes } from "./chart-types";
describe("chart-types", () => {
test("CHART_TYPE_ICONS has all chart types", () => {
expect(Object.keys(CHART_TYPE_ICONS)).toEqual(["area", "bar", "line", "pie", "big_number"]);
});
test("getChartTypes returns chart types with translated labels", () => {
const t = vi.fn((key: string) => key);
const result = getChartTypes(t);
expect(result).toHaveLength(5);
expect(result.map((r) => r.id)).toEqual(["area", "bar", "line", "pie", "big_number"]);
expect(t).toHaveBeenCalledWith("environments.analysis.charts.chart_type_area");
expect(result[0].label).toBe("environments.analysis.charts.chart_type_area");
});
});
@@ -0,0 +1,35 @@
import type { TFunction } from "i18next";
import { ActivityIcon, AreaChartIcon, BarChart3Icon, LineChartIcon, PieChartIcon } from "lucide-react";
import type React from "react";
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
export const CHART_TYPE_ICONS: Record<
TChartType,
React.ComponentType<{ className?: string; strokeWidth?: number }>
> = {
area: AreaChartIcon,
bar: BarChart3Icon,
line: LineChartIcon,
pie: PieChartIcon,
big_number: ActivityIcon,
};
export function getChartTypes(
t: TFunction
): readonly {
id: TChartType;
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
label: string;
}[] {
return [
{ id: "area", icon: CHART_TYPE_ICONS.area, label: t("environments.analysis.charts.chart_type_area") },
{ id: "bar", icon: CHART_TYPE_ICONS.bar, label: t("environments.analysis.charts.chart_type_bar") },
{ id: "line", icon: CHART_TYPE_ICONS.line, label: t("environments.analysis.charts.chart_type_line") },
{ id: "pie", icon: CHART_TYPE_ICONS.pie, label: t("environments.analysis.charts.chart_type_pie") },
{
id: "big_number",
icon: CHART_TYPE_ICONS.big_number,
label: t("environments.analysis.charts.chart_type_big_number"),
},
];
}
@@ -380,4 +380,35 @@ describe("Chart Service", () => {
});
});
});
describe("getChartsWithCreator", () => {
test("returns charts with creator info", async () => {
const chartsWithCreator = [
{ ...mockChart, creator: { name: "Alice" } },
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: null },
];
vi.mocked(prisma.chart.findMany).mockResolvedValue(chartsWithCreator as any);
const { getChartsWithCreator } = await import("./charts");
const result = await getChartsWithCreator(mockProjectId);
expect(result).toEqual(chartsWithCreator);
expect(prisma.chart.findMany).toHaveBeenCalledWith({
where: { projectId: mockProjectId },
orderBy: { createdAt: "desc" },
select: expect.objectContaining({
creator: { select: { name: true } },
}),
});
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
const { getChartsWithCreator } = await import("./charts");
await expect(getChartsWithCreator(mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
});
@@ -10,6 +10,7 @@ import {
TChart,
TChartCreateInput,
TChartUpdateInput,
TChartWithCreator,
TChartWithWidgets,
ZChartCreateInput,
ZChartType,
@@ -244,3 +245,25 @@ export const getCharts = async (projectId: string): Promise<TChartWithWidgets[]>
throw error;
}
};
export const getChartsWithCreator = async (projectId: string): Promise<TChartWithCreator[]> => {
validateInputs([projectId, ZId]);
try {
return await prisma.chart.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
...selectChart,
creator: {
select: { name: true },
},
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -25,8 +25,11 @@ const DashboardsListContent = ({
return <DashboardsTable dashboards={dashboards} environmentId={environmentId} isReadOnly={isReadOnly} />;
};
export const DashboardsListPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const { environmentId } = await props.params;
interface DashboardsListPageProps {
environmentId: string;
}
export const DashboardsListPage = async ({ environmentId }: Readonly<DashboardsListPageProps>) => {
const t = await getTranslate();
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
@@ -41,6 +41,10 @@ export type TChartWithWidgets = TChart & {
widgets: { dashboardId: string }[];
};
export type TChartWithCreator = TChart & {
creator: { name: string } | null;
};
// ── Dashboard input schemas ─────────────────────────────────────────────────
export const ZDashboardCreateInput = z.object({