refactor: chart list cleanups

This commit is contained in:
Dhruwang
2026-02-18 15:11:58 +05:30
parent d50c8fb401
commit b03eaf03b1
8 changed files with 161 additions and 84 deletions

View File

@@ -0,0 +1,5 @@
import { ChartsListSkeleton } from "@/modules/ee/analysis/components/charts-list-skeleton";
export default function ChartsListLoading() {
return <ChartsListSkeleton />;
}

View File

@@ -1,3 +1 @@
import { ChartsListPage } from "@/modules/ee/analysis/pages/charts-list-page";
export default ChartsListPage;
export { ChartsListPage as default } from "@/modules/ee/analysis/pages/charts-list-page";

View File

@@ -400,6 +400,7 @@
"styling": "Styling",
"submit": "Submit",
"summary": "Summary",
"chart": "Chart",
"survey": "Survey",
"survey_completed": "Survey completed.",
"survey_id": "Survey ID",
@@ -619,6 +620,15 @@
"subtitle": "It takes less than 4 minutes.",
"waiting_for_your_signal": "Waiting for your signal…"
},
"analysis": {
"charts": {
"chart_deleted_successfully": "Chart deleted successfully",
"chart_duplicated_successfully": "Chart duplicated successfully",
"chart_duplication_error": "Failed to duplicate chart",
"delete_chart_confirmation": "Are you sure you want to delete this chart? This action cannot be undone.",
"open_options": "Open options"
}
},
"contacts": {
"add_attribute": "Add Attribute",
"attribute_created_successfully": "Attribute created successfully",

View File

@@ -143,6 +143,68 @@ export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAc
)
);
const ZDuplicateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const duplicateChartAction = authenticatedActionClient.schema(ZDuplicateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDuplicateChartAction>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const sourceChart = await prisma.chart.findFirst({
where: { id: parsedInput.chartId, projectId },
});
if (!sourceChart) {
throw new Error("Chart not found");
}
const duplicatedChart = await prisma.chart.create({
data: {
name: `${sourceChart.name} (copy)`,
type: sourceChart.type,
projectId,
query: sourceChart.query as object,
config: (sourceChart.config as object) || {},
createdBy: ctx.user.id,
},
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.newObject = duplicatedChart;
return duplicatedChart;
}
)
);
// --- Dashboard widget actions ---
const ZAddChartToDashboardAction = z.object({

View File

@@ -4,7 +4,6 @@ import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
@@ -13,25 +12,25 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { deleteChartAction } from "../actions";
import { Button } from "@/modules/ui/components/button";
import { deleteChartAction, duplicateChartAction } from "../actions";
import { TChart } from "../types/analysis";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useRouter } from "next/navigation";
interface ChartDropdownMenuProps {
environmentId: string;
chart: TChart;
disabled?: boolean;
deleteChart: (chartId: string) => void;
onEdit?: () => void;
onEdit?: (chartId: string) => void;
}
export const ChartDropdownMenu = ({
environmentId,
chart,
disabled,
deleteChart,
onEdit,
}: ChartDropdownMenuProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
@@ -41,33 +40,64 @@ export const ChartDropdownMenu = ({
try {
const result = await deleteChartAction({ environmentId, chartId });
if (result?.data) {
deleteChart(chartId);
toast.success("Chart deleted successfully");
toast.success(t("environments.analysis.charts.chart_deleted_successfully"));
setDeleteDialogOpen(false);
router.refresh();
} else {
toast.error(result?.serverError || "Failed to delete chart");
toast.error(getFormattedErrorMessage(result));
}
} catch (error) {
toast.error("Error deleting chart");
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setLoading(false);
}
};
const closeDropdown = () => {
setTimeout(() => setIsDropDownOpen(false), 0);
};
const handleDuplicateChart = async () => {
closeDropdown();
setLoading(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 {
setLoading(false);
}
};
const handleEdit = () => {
closeDropdown();
setTimeout(() => onEdit?.(chart.id), 0);
};
const handleOpenDeleteDialog = () => {
closeDropdown();
setTimeout(() => setDeleteDialogOpen(true), 0);
};
return (
<div
id={`${chart.name.toLowerCase().split(" ").join("-")}-chart-actions`}
data-testid="chart-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
<div
className={cn(
"rounded-lg border bg-white p-2",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
)}
<DropdownMenuTrigger className="z-10" asChild>
<Button
variant="outline"
className="px-2"
onClick={(e) => e.stopPropagation()}>
<span className="sr-only">Open options</span>
<span className="sr-only">{t("environments.analysis.charts.open_options")}</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max">
<DropdownMenuGroup>
@@ -77,8 +107,7 @@ export const ChartDropdownMenu = ({
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
onEdit?.();
handleEdit();
}}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
@@ -89,11 +118,11 @@ export const ChartDropdownMenu = ({
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
toast.success("Duplicate functionality coming soon");
}}>
handleDuplicateChart();
}}
disabled={loading}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
@@ -105,8 +134,7 @@ export const ChartDropdownMenu = ({
className="flex w-full items-center"
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
handleOpenDeleteDialog();
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
@@ -117,11 +145,11 @@ export const ChartDropdownMenu = ({
</DropdownMenu>
<DeleteDialog
deleteWhat="Chart"
deleteWhat={t("common.chart")}
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteChart(chart.id)}
text="Are you sure you want to delete this chart? This action cannot be undone."
text={t("environments.analysis.charts.delete_chart_confirmation")}
isDeleting={loading}
/>
</div>

View File

@@ -1,49 +1,25 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import {
ActivityIcon,
AreaChartIcon,
BarChart3Icon,
LineChartIcon,
MapIcon,
PieChartIcon,
ScatterChart,
TableIcon,
} from "lucide-react";
import { BarChart3Icon } from "lucide-react";
import { useState } from "react";
import { TChart, TDashboard } from "../types/analysis";
import { useTranslation } from "react-i18next";
import { CHART_TYPE_ICONS } from "../lib/chart-types";
import { TChart } from "../types/analysis";
import { ChartDropdownMenu } from "./chart-dropdown-menu";
import { CreateChartDialog } from "./create-chart-dialog";
interface ChartsListClientProps {
interface ChartsListProps {
charts: TChart[];
dashboards: TDashboard[];
environmentId: string;
}
const CHART_TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
area: AreaChartIcon,
bar: BarChart3Icon,
line: LineChartIcon,
pie: PieChartIcon,
table: TableIcon,
big_number: ActivityIcon,
big_number_total: ActivityIcon,
scatter: ScatterChart,
map: MapIcon,
};
export function ChartsListClient({ charts: initialCharts, environmentId }: ChartsListClientProps) {
const [charts, setCharts] = useState(initialCharts);
export function ChartsList({ charts, environmentId }: Readonly<ChartsListProps>) {
const [editingChartId, setEditingChartId] = useState<string | undefined>(undefined);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const { t } = useTranslation();
const filteredCharts = charts;
const deleteChart = (chartId: string) => {
setCharts(charts.filter((c) => c.id !== chartId));
};
const getChartIcon = (type: string) => {
const IconComponent = CHART_TYPE_ICONS[type] || BarChart3Icon;
@@ -63,10 +39,10 @@ export function ChartsListClient({ charts: initialCharts, environmentId }: Chart
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">Title</div>
<div className="col-span-1 hidden text-center sm:block">Created By</div>
<div className="col-span-1 hidden text-center sm:block">Created</div>
<div className="col-span-1 hidden text-center sm:block">Updated</div>
<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>
</div>
{filteredCharts.length === 0 ? (
@@ -109,9 +85,8 @@ export function ChartsListClient({ charts: initialCharts, environmentId }: Chart
<ChartDropdownMenu
environmentId={environmentId}
chart={chart}
deleteChart={deleteChart}
onEdit={() => {
setEditingChartId(chart.id);
onEdit={(chartId) => {
setEditingChartId(chartId);
setIsEditDialogOpen(true);
}}
/>

View File

@@ -1,3 +1,4 @@
import type React from "react";
import {
ActivityIcon,
AreaChartIcon,
@@ -19,3 +20,7 @@ export const CHART_TYPES = [
{ id: "scatter", name: "Scatter Plot", icon: ScatterChart },
{ id: "map", name: "World Map", icon: MapIcon },
] as const;
export const CHART_TYPE_ICONS = Object.fromEntries(
CHART_TYPES.map(({ id, icon }) => [id, icon])
) as Record<string, React.ComponentType<{ className?: string }>>;

View File

@@ -1,19 +1,13 @@
import { Suspense } from "react";
import { ChartsListClient } from "../components/charts-list-client";
import { ChartsListSkeleton } from "../components/charts-list-skeleton";
import { getCharts, getDashboards } from "../lib/data";
import { ChartsList } from "../components/charts-list";
import { getCharts } from "../lib/data";
async function ChartsListContent({ environmentId }: { environmentId: string }) {
const [charts, dashboards] = await Promise.all([getCharts(environmentId), getDashboards(environmentId)]);
return <ChartsListClient charts={charts} dashboards={dashboards} environmentId={environmentId} />;
interface ChartsListPageProps {
params: Promise<{ environmentId: string }>;
}
export async function ChartsListPage({ params }: { params: Promise<{ environmentId: string }> }) {
export async function ChartsListPage({ params }: Readonly<ChartsListPageProps>) {
const { environmentId } = await params;
const charts = await getCharts(environmentId);
return (
<Suspense fallback={<ChartsListSkeleton />}>
<ChartsListContent environmentId={environmentId} />
</Suspense>
);
return <ChartsList charts={charts} environmentId={environmentId} />;
}