Compare commits

...

1 Commits

Author SHA1 Message Date
TheodorTomas
7e6a35b3d0 feat: adding dnd to dashboard page 2026-02-19 16:21:23 +07:00
14 changed files with 842 additions and 280 deletions

View File

@@ -123,6 +123,8 @@
"add": "Add",
"add_action": "Add action",
"add_chart": "Add chart",
"add_existing_chart": "Add existing chart",
"add_existing_chart_description": "Search and select charts to add to this dashboard.",
"add_filter": "Add filter",
"add_logo": "Add logo",
"add_member": "Add member",
@@ -180,6 +182,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_new_chart": "Create new chart",
"create_new_organization": "Create new organization",
"create_segment": "Create segment",
"create_survey": "Create survey",
@@ -347,6 +350,7 @@
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
"read_docs": "Read docs",
"recipients": "Recipients",
"refresh": "Refresh",
"remove": "Remove",
"remove_from_team": "Remove from team",
"reorder_and_hide_columns": "Reorder and hide columns",
@@ -367,6 +371,7 @@
"save_changes": "Save changes",
"saving": "Saving",
"search": "Search",
"search_charts": "Search charts...",
"security": "Security",
"segment": "Segment",
"segments": "Segments",

View File

@@ -20,6 +20,18 @@ import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/
const BRAND_DARK = "#00C4B8";
const BRAND_LIGHT = "#00E6CA";
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T|\s)/;
function formatTickValue(value: unknown): string {
if (typeof value !== "string") return String(value ?? "");
if (!ISO_DATE_RE.test(value)) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
interface ChartRendererProps {
chartType: string;
data: Record<string, unknown>[];
@@ -27,7 +39,7 @@ interface ChartRendererProps {
export function ChartRenderer({ chartType, data }: ChartRendererProps) {
if (!data || data.length === 0) {
return <div className="flex h-64 items-center justify-center text-gray-500">No data available</div>;
return <div className="flex h-full min-h-[200px] items-center justify-center text-gray-500">No data available</div>;
}
// Get the first data point to determine keys
@@ -71,13 +83,13 @@ export function ChartRenderer({ chartType, data }: ChartRendererProps) {
switch (chartType) {
case "bar":
return (
<div className="h-64 min-h-[256px] w-full">
<div className="h-full w-full">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full">
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} tickFormatter={formatTickValue} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey={dataKey} fill={BRAND_DARK} radius={4} />
@@ -87,13 +99,13 @@ export function ChartRenderer({ chartType, data }: ChartRendererProps) {
);
case "line":
return (
<div className="h-64 min-h-[256px] w-full">
<div className="h-full w-full">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} tickFormatter={formatTickValue} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Line
@@ -110,13 +122,13 @@ export function ChartRenderer({ chartType, data }: ChartRendererProps) {
);
case "area":
return (
<div className="h-64 min-h-[256px] w-full">
<div className="h-full w-full">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full">
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} tickFormatter={formatTickValue} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
@@ -135,7 +147,7 @@ export function ChartRenderer({ chartType, data }: ChartRendererProps) {
case "donut": {
if (!dataKey || !xAxisKey) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">
<div className="flex h-full min-h-[200px] items-center justify-center text-gray-500">
Unable to determine chart data structure
</div>
);
@@ -157,7 +169,7 @@ export function ChartRenderer({ chartType, data }: ChartRendererProps) {
if (processedData.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-gray-500">No valid data to display</div>
<div className="flex h-full min-h-[200px] items-center justify-center text-gray-500">No valid data to display</div>
);
}
@@ -173,18 +185,18 @@ export function ChartRenderer({ chartType, data }: ChartRendererProps) {
if (colors.length > 1) colors[1] = BRAND_LIGHT;
return (
<div className="h-64 min-h-[256px] w-full min-w-0">
<div className="h-full w-full min-w-0">
<ChartContainer
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
className="h-full w-full min-w-0">
<PieChart width={400} height={256}>
<PieChart>
<Pie
data={processedData}
dataKey={dataKey}
nameKey={xAxisKey}
cx="50%"
cy="50%"
outerRadius={80}
outerRadius="70%"
label={({ name, percent }) => {
if (!percent) return "";
return `${name}: ${(percent * 100).toFixed(0)}%`;
@@ -211,7 +223,7 @@ export function ChartRenderer({ chartType, data }: ChartRendererProps) {
case "big_number": {
const total = data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0);
return (
<div className="flex h-64 items-center justify-center">
<div className="flex h-full min-h-[100px] items-center justify-center">
<div className="text-center">
<div className="text-4xl font-bold text-gray-900">{total.toLocaleString()}</div>
<div className="mt-2 text-sm text-gray-500">{dataKey}</div>
@@ -221,7 +233,7 @@ export function ChartRenderer({ chartType, data }: ChartRendererProps) {
}
default:
return (
<div className="flex h-64 items-center justify-center text-gray-500">
<div className="flex h-full min-h-[200px] items-center justify-center text-gray-500">
Chart type &quot;{chartType}&quot; not yet supported
</div>
);

View File

@@ -206,6 +206,91 @@ export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDas
)
);
const ZUpdateWidgetLayoutsAction = z.object({
environmentId: ZId,
dashboardId: ZId,
widgets: z.array(
z.object({
id: z.string(),
layout: ZWidgetLayout,
order: z.number(),
})
),
});
export const updateWidgetLayoutsAction = authenticatedActionClient.schema(ZUpdateWidgetLayoutsAction).action(
withAuditLogging(
"updated",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateWidgetLayoutsAction>;
}) => {
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 dashboard = await prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId, projectId },
include: { widgets: true },
});
if (!dashboard) {
throw new Error("Dashboard not found");
}
const widgetIds = parsedInput.widgets.map((w) => w.id);
const existingWidgetIds = dashboard.widgets.map((w) => w.id);
const invalidIds = widgetIds.filter((id) => !existingWidgetIds.includes(id));
if (invalidIds.length > 0) {
throw new Error(`Invalid widget IDs: ${invalidIds.join(", ")}`);
}
await prisma.$transaction(
parsedInput.widgets.map((widget) =>
prisma.dashboardWidget.update({
where: { id: widget.id },
data: {
layout: widget.layout,
order: widget.order,
},
})
)
);
const updatedDashboard = await prisma.dashboard.findFirst({
where: { id: parsedInput.dashboardId },
include: { widgets: true },
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.oldObject = dashboard;
ctx.auditLoggingCtx.newObject = updatedDashboard;
return { success: true, widgetCount: parsedInput.widgets.length };
}
)
);
const ZDeleteDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,

View File

@@ -0,0 +1,158 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
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 { MultiSelect } from "@/modules/ui/components/multi-select";
import { getChartsAction } from "@/modules/ee/analysis/charts/actions";
import { addChartToDashboardAction } from "../actions";
import { TDashboard } from "../../types/analysis";
interface AddExistingChartsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
dashboard: TDashboard;
onSuccess: () => void;
}
interface ChartOption {
value: string;
label: string;
}
export function AddExistingChartsDialog({
open,
onOpenChange,
environmentId,
dashboard,
onSuccess,
}: AddExistingChartsDialogProps) {
const { t } = useTranslation();
const [chartOptions, setChartOptions] = useState<ChartOption[]>([]);
const [selectedChartIds, setSelectedChartIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const existingChartIds = useMemo(
() => new Set(dashboard.widgets.filter((w) => w.chartId).map((w) => w.chartId!)),
[dashboard.widgets]
);
useEffect(() => {
if (!open) return;
const loadCharts = async () => {
setIsLoading(true);
setSelectedChartIds([]);
try {
const result = await getChartsAction({ environmentId });
if (result?.data) {
setChartOptions(
result.data.map((chart) => ({
value: chart.id,
label: existingChartIds.has(chart.id) ? `${chart.name} (already added)` : chart.name,
}))
);
} else {
toast.error(result?.serverError || "Failed to load charts");
}
} catch {
toast.error("Failed to load charts");
} finally {
setIsLoading(false);
}
};
loadCharts();
}, [open, environmentId, existingChartIds]);
const handleAdd = async () => {
if (selectedChartIds.length === 0) return;
setIsAdding(true);
try {
const results = await Promise.all(
selectedChartIds.map((chartId) =>
addChartToDashboardAction({
environmentId,
chartId,
dashboardId: dashboard.id,
})
)
);
const failures = results.filter((r) => !r?.data);
if (failures.length > 0) {
toast.error(`Failed to add ${failures.length} chart(s)`);
} else {
toast.success(
selectedChartIds.length === 1
? "Chart added to dashboard"
: `${selectedChartIds.length} charts added to dashboard`
);
}
onSuccess();
} catch {
toast.error("Failed to add charts to dashboard");
} finally {
setIsAdding(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("common.add_existing_chart")}</DialogTitle>
<DialogDescription>{t("common.add_existing_chart_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
{isLoading ? (
<div className="flex items-center justify-center rounded-md border px-3 py-2">
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
</div>
) : chartOptions.length === 0 ? (
<div className="flex h-20 items-center justify-center">
<p className="text-sm text-gray-500">
No charts exist yet. Create one first using the + button.
</p>
</div>
) : (
<MultiSelect
options={chartOptions}
value={selectedChartIds}
onChange={setSelectedChartIds}
placeholder={t("common.search_charts")}
/>
)}
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
{t("common.cancel")}
</Button>
<Button
onClick={handleAdd}
loading={isAdding}
disabled={selectedChartIds.length === 0 || isAdding}>
{selectedChartIds.length > 0
? `Add ${selectedChartIds.length} chart${selectedChartIds.length > 1 ? "s" : ""}`
: t("common.add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { CopyIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
import { CheckIcon, LibraryIcon, LockOpenIcon, PlusIcon, RefreshCwIcon, TrashIcon, XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -10,25 +10,37 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { deleteDashboardAction } from "../actions";
import { TDashboard } from "@/modules/ee/analysis/types/analysis";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import { EditDashboardDialog } from "./edit-dashboard-dialog";
import { AddExistingChartsDialog } from "./add-existing-charts-dialog";
interface DashboardControlBarProps {
environmentId: string;
dashboard: TDashboard;
onDashboardUpdate?: () => void;
isEditing: boolean;
isSaving: boolean;
hasChanges: boolean;
onRefresh: () => void;
onEditToggle: () => void;
onSave: () => void;
onCancel: () => void;
}
export const DashboardControlBar = ({
environmentId,
dashboard,
onDashboardUpdate,
isEditing,
isSaving,
hasChanges,
onRefresh,
onEditToggle,
onSave,
onCancel,
}: DashboardControlBarProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isAddChartDialogOpen, setIsAddChartDialogOpen] = useState(false);
const [isCreateChartDialogOpen, setIsCreateChartDialogOpen] = useState(false);
const [isAddExistingDialogOpen, setIsAddExistingDialogOpen] = useState(false);
const handleDeleteDashboard = async () => {
setIsDeleting(true);
@@ -49,33 +61,53 @@ export const DashboardControlBar = ({
}
};
const handleDuplicate = async () => {
toast.success("Duplicate functionality coming soon");
};
const editModeActions = [
{
icon: CheckIcon,
tooltip: hasChanges ? t("common.save") : t("common.no_changes"),
onClick: onSave,
isVisible: true,
isLoading: isSaving,
disabled: isSaving || !hasChanges,
},
{
icon: XIcon,
tooltip: t("common.cancel"),
onClick: onCancel,
isVisible: true,
disabled: isSaving,
},
];
const iconActions = [
const viewModeActions = [
{
icon: RefreshCwIcon,
tooltip: t("common.refresh"),
onClick: onRefresh,
isVisible: true,
},
{
icon: LockOpenIcon,
tooltip: t("common.unlock"),
onClick: onEditToggle,
isVisible: true,
},
{
icon: LibraryIcon,
tooltip: t("common.add_existing_chart"),
onClick: () => {
setIsAddExistingDialogOpen(true);
},
isVisible: true,
},
{
icon: PlusIcon,
tooltip: t("common.add_chart"),
tooltip: t("common.create_new_chart"),
onClick: () => {
setIsAddChartDialogOpen(true);
setIsCreateChartDialogOpen(true);
},
isVisible: true,
},
{
icon: PencilIcon,
tooltip: t("common.edit"),
onClick: () => {
setIsEditDialogOpen(true);
},
isVisible: true,
},
{
icon: CopyIcon,
tooltip: t("common.duplicate"),
onClick: handleDuplicate,
isVisible: true,
},
{
icon: TrashIcon,
tooltip: t("common.delete"),
@@ -88,7 +120,7 @@ export const DashboardControlBar = ({
return (
<>
<IconBar actions={iconActions} />
<IconBar actions={isEditing ? editModeActions : viewModeActions} />
<DeleteDialog
deleteWhat="Dashboard"
open={isDeleteDialogOpen}
@@ -97,25 +129,22 @@ export const DashboardControlBar = ({
text="Are you sure you want to delete this dashboard? This action cannot be undone."
isDeleting={isDeleting}
/>
<EditDashboardDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
dashboardId={dashboard.id}
<AddExistingChartsDialog
open={isAddExistingDialogOpen}
onOpenChange={setIsAddExistingDialogOpen}
environmentId={environmentId}
initialName={dashboard.name}
initialDescription={dashboard.description}
dashboard={dashboard}
onSuccess={() => {
setIsEditDialogOpen(false);
onDashboardUpdate?.();
setIsAddExistingDialogOpen(false);
router.refresh();
}}
/>
<CreateChartDialog
open={isAddChartDialogOpen}
onOpenChange={setIsAddChartDialogOpen}
open={isCreateChartDialogOpen}
onOpenChange={setIsCreateChartDialogOpen}
environmentId={environmentId}
onSuccess={() => {
setIsAddChartDialogOpen(false);
setIsCreateChartDialogOpen(false);
router.refresh();
}}
/>

View File

@@ -0,0 +1,330 @@
"use client";
import { Delay } from "@suspensive/react";
import { memo, Suspense, useCallback, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import {
ResponsiveGridLayout,
useContainerWidth,
verticalCompactor,
} from "react-grid-layout";
import type { Layout, LayoutItem } from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
const gridStyles = `
.react-grid-item.react-draggable-dragging {
opacity: 0.7;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
z-index: 100;
}
`;
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { DashboardControlBar } from "./dashboard-control-bar";
import { DashboardWidgetSkeleton } from "./dashboard-widget-skeleton";
import { DashboardWidget } from "./dashboard-widget";
import { DashboardWidgetData } from "./dashboard-widget-data";
import { EditableDashboardHeader } from "./editable-dashboard-header";
import { TDashboard, TDashboardWidget } from "../../types/analysis";
import { updateDashboardAction, updateWidgetLayoutsAction } from "../actions";
const ROW_HEIGHT = 80;
interface DashboardDetailClientProps {
environmentId: string;
dashboard: TDashboard;
widgetDataPromises: Map<string, Promise<{ data: Record<string, unknown>[] } | { error: string }>>;
}
function StaticWidgetContent({ widget }: { widget: TDashboardWidget }) {
if (widget.type === "markdown") {
return (
<div className="prose prose-sm max-w-none">
<p className="text-gray-500">Markdown widget placeholder</p>
</div>
);
}
if (widget.type === "header") {
return (
<div className="flex h-full items-center">
<h2 className="text-2xl font-semibold text-gray-900">{widget.title || "Header"}</h2>
</div>
);
}
if (widget.type === "divider") {
return <div className="h-full w-full border-t border-gray-200" />;
}
return null;
}
function widgetsToLayout(widgets: TDashboardWidget[]): LayoutItem[] {
return widgets.map((widget) => ({
i: widget.id,
x: widget.layout.x,
y: widget.layout.y,
w: widget.layout.w,
h: widget.layout.h,
minW: 2,
minH: 2,
maxW: 12,
maxH: 8,
}));
}
function applyLayoutToWidgets(widgets: TDashboardWidget[], newLayout: Layout): TDashboardWidget[] {
let changed = false;
const updated = widgets.map((widget) => {
const layoutItem = newLayout.find((l) => l.i === widget.id);
if (!layoutItem) return widget;
if (
widget.layout.x === layoutItem.x &&
widget.layout.y === layoutItem.y &&
widget.layout.w === layoutItem.w &&
widget.layout.h === layoutItem.h
) {
return widget;
}
changed = true;
return {
...widget,
layout: {
x: layoutItem.x,
y: layoutItem.y,
w: layoutItem.w,
h: layoutItem.h,
},
};
});
return changed ? updated : widgets;
}
const MemoizedWidgetContent = memo(function WidgetContent({
widget,
dataPromise,
}: {
widget: TDashboardWidget;
dataPromise?: Promise<{ data: Record<string, unknown>[] } | { error: string }>;
}) {
if (widget.type === "chart" && widget.chart) {
if (dataPromise) {
return (
<Suspense
fallback={
<Delay ms={200}>
<DashboardWidgetSkeleton />
</Delay>
}>
<DashboardWidgetData dataPromise={dataPromise} chartType={widget.chart.type} />
</Suspense>
);
}
return <DashboardWidgetSkeleton />;
}
return <StaticWidgetContent widget={widget} />;
});
const MemoizedWidgetItem = memo(function WidgetItem({
widget,
isEditing,
dataPromise,
onRemove,
}: {
widget: TDashboardWidget;
isEditing: boolean;
dataPromise?: Promise<{ data: Record<string, unknown>[] } | { error: string }>;
onRemove?: () => void;
}) {
const title =
widget.title || (widget.type === "chart" && widget.chart ? widget.chart.name : "Widget") || "Widget";
return (
<DashboardWidget title={title} isEditing={isEditing} onRemove={onRemove}>
<MemoizedWidgetContent widget={widget} dataPromise={dataPromise} />
</DashboardWidget>
);
});
export function DashboardDetailClient({
environmentId,
dashboard,
widgetDataPromises,
}: DashboardDetailClientProps) {
const router = useRouter();
const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 });
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [name, setName] = useState(dashboard.name);
const [description, setDescription] = useState(dashboard.description || "");
const [widgets, setWidgets] = useState<TDashboardWidget[]>(dashboard.widgets);
const hasChanges = useMemo(() => {
if (name !== dashboard.name) return true;
if (description !== (dashboard.description || "")) return true;
if (JSON.stringify(widgets) !== JSON.stringify(dashboard.widgets)) return true;
return false;
}, [name, description, widgets, dashboard]);
const layout = useMemo(() => widgetsToLayout(widgets), [widgets]);
const handleInteractionEnd = useCallback((finalLayout: Layout) => {
setWidgets((current) => applyLayoutToWidgets(current, finalLayout));
}, []);
const handleRemoveWidget = useCallback((widgetId: string) => {
setWidgets((current) => current.filter((w) => w.id !== widgetId));
}, []);
const handleCancel = useCallback(() => {
setName(dashboard.name);
setDescription(dashboard.description || "");
setWidgets(dashboard.widgets);
setIsEditing(false);
}, [dashboard]);
const handleSave = useCallback(async () => {
if (!name.trim()) {
toast.error("Dashboard name is required");
return;
}
setIsSaving(true);
try {
if (name !== dashboard.name || description !== (dashboard.description || "")) {
const dashboardResult = await updateDashboardAction({
environmentId,
dashboardId: dashboard.id,
name: name.trim(),
description: description.trim() || null,
});
if (!dashboardResult?.data) {
toast.error(dashboardResult?.serverError || "Failed to update dashboard");
setIsSaving(false);
return;
}
}
if (JSON.stringify(widgets) !== JSON.stringify(dashboard.widgets)) {
const widgetUpdates = widgets.map((widget, i) => ({
id: widget.id,
layout: widget.layout,
order: i,
}));
const widgetsResult = await updateWidgetLayoutsAction({
environmentId,
dashboardId: dashboard.id,
widgets: widgetUpdates,
});
if (!widgetsResult?.data) {
toast.error(widgetsResult?.serverError || "Failed to update widget layouts");
setIsSaving(false);
return;
}
}
toast.success("Dashboard saved successfully");
setIsEditing(false);
router.refresh();
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to save dashboard";
toast.error(message);
} finally {
setIsSaving(false);
}
}, [name, description, widgets, dashboard, environmentId, router]);
const isEmpty = widgets.length === 0;
return (
<PageContentWrapper>
{/* eslint-disable-next-line react/no-danger */}
<style dangerouslySetInnerHTML={{ __html: gridStyles }} />
<GoBackButton url={`/environments/${environmentId}/analysis/dashboards`} />
<EditableDashboardHeader
name={name}
description={description}
isEditing={isEditing}
onNameChange={setName}
onDescriptionChange={setDescription}
>
<DashboardControlBar
environmentId={environmentId}
dashboard={dashboard}
isEditing={isEditing}
isSaving={isSaving}
hasChanges={hasChanges}
onRefresh={() => router.refresh()}
onEditToggle={() => setIsEditing(true)}
onSave={handleSave}
onCancel={handleCancel}
/>
</EditableDashboardHeader>
<section className="pb-24 pt-6">
{isEmpty ? (
<div className="flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/50">
<div className="mb-4 rounded-full bg-gray-100 p-4">
<div className="h-12 w-12 rounded-md bg-gray-300 opacity-20" />
</div>
<h3 className="text-lg font-medium text-gray-900">No Data</h3>
<p className="mt-2 max-w-sm text-center text-gray-500">
There is currently no information to display. Add charts to build your dashboard.
</p>
<CreateChartButton environmentId={environmentId} />
</div>
) : (
<div ref={containerRef}>
{mounted && (
<ResponsiveGridLayout
width={width}
layouts={{ lg: layout }}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
rowHeight={ROW_HEIGHT}
margin={[16, 16]}
dragConfig={{
enabled: isEditing,
handle: ".rgl-drag-handle",
bounded: false,
threshold: 5,
}}
resizeConfig={{
enabled: isEditing,
handles: ["n", "s", "e", "w", "ne", "nw", "se", "sw"],
}}
compactor={verticalCompactor}
onDragStop={(finalLayout) => handleInteractionEnd(finalLayout)}
onResizeStop={(finalLayout) => handleInteractionEnd(finalLayout)}
>
{widgets.map((widget) => (
<div key={widget.id}>
<MemoizedWidgetItem
widget={widget}
isEditing={isEditing}
dataPromise={widgetDataPromises.get(widget.id)}
onRemove={isEditing ? () => handleRemoveWidget(widget.id) : undefined}
/>
</div>
))}
</ResponsiveGridLayout>
)}
</div>
)}
</section>
</PageContentWrapper>
);
}

View File

@@ -1,17 +1,67 @@
import { ReactNode } from "react";
"use client";
import { MoreVerticalIcon, TrashIcon } from "lucide-react";
import { ReactNode, useState } from "react";
import { cn } from "@/lib/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
interface DashboardWidgetProps {
title: string;
children: ReactNode;
isEditing?: boolean;
onRemove?: () => void;
}
export function DashboardWidget({ title, children }: DashboardWidgetProps) {
export function DashboardWidget({ title, children, isEditing, onRemove }: DashboardWidgetProps) {
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="flex h-full flex-col rounded-sm border border-gray-200 bg-white shadow-sm ring-1 ring-black/5">
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-2">
<h3 className="text-sm font-semibold text-gray-800">{title}</h3>
<div
className={cn(
"flex h-full flex-col rounded-sm border border-gray-200 bg-white shadow-sm ring-2 ring-transparent",
isEditing && "ring-brand-dark/20 transition-shadow hover:ring-brand-dark/40"
)}
>
<div
className={cn(
"flex h-10 items-center justify-between border-b border-gray-100 px-4",
isEditing && "rgl-drag-handle cursor-grab active:cursor-grabbing"
)}
>
<h3 className="flex-1 truncate text-sm font-semibold text-gray-800">{title}</h3>
{onRemove && (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="ml-2 shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<MoreVerticalIcon className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onSelect={() => {
setMenuOpen(false);
onRemove();
}}
className="text-red-600 focus:text-red-600"
>
<TrashIcon className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="relative min-h-[300px] flex-1 p-4">{children}</div>
<div className="relative flex-1 overflow-hidden p-4">{children}</div>
</div>
);
}

View File

@@ -1,127 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
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 { updateDashboardAction } from "../actions";
interface EditDashboardDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
dashboardId: string;
environmentId: string;
initialName: string;
initialDescription?: string;
onSuccess: () => void;
}
export function EditDashboardDialog({
open,
onOpenChange,
dashboardId,
environmentId,
initialName,
initialDescription,
onSuccess,
}: EditDashboardDialogProps) {
const [dashboardName, setDashboardName] = useState(initialName);
const [dashboardDescription, setDashboardDescription] = useState(initialDescription || "");
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (open) {
setDashboardName(initialName);
setDashboardDescription(initialDescription || "");
}
}, [open, initialName, initialDescription]);
const handleSave = async () => {
if (!dashboardName.trim()) {
toast.error("Please enter a dashboard name");
return;
}
setIsSaving(true);
try {
const result = await updateDashboardAction({
environmentId,
dashboardId,
name: dashboardName.trim(),
description: dashboardDescription.trim() || null,
});
if (!result?.data) {
toast.error(result?.serverError || "Failed to update dashboard");
return;
}
toast.success("Dashboard updated successfully!");
onSuccess();
onOpenChange(false);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to update dashboard";
toast.error(message);
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Dashboard</DialogTitle>
<DialogDescription>Update dashboard name and description.</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4">
<div className="space-y-2">
<label htmlFor="edit-dashboard-name" className="text-sm font-medium text-gray-900">
Dashboard Name
</label>
<Input
id="edit-dashboard-name"
placeholder="Dashboard name"
value={dashboardName}
onChange={(e) => setDashboardName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && dashboardName.trim() && !isSaving) {
handleSave();
}
}}
autoFocus
/>
</div>
<div className="space-y-2">
<label htmlFor="edit-dashboard-description" className="text-sm font-medium text-gray-900">
Description (Optional)
</label>
<Input
id="edit-dashboard-description"
placeholder="Dashboard description"
value={dashboardDescription}
onChange={(e) => setDashboardDescription(e.target.value)}
/>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Cancel
</Button>
<Button onClick={handleSave} loading={isSaving} disabled={!dashboardName.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { ReactNode } from "react";
interface EditableDashboardHeaderProps {
name: string;
description: string;
isEditing: boolean;
onNameChange: (name: string) => void;
onDescriptionChange: (description: string) => void;
children?: ReactNode;
}
export function EditableDashboardHeader({
name,
description,
isEditing,
onNameChange,
onDescriptionChange,
children,
}: EditableDashboardHeaderProps) {
return (
<div className="border-b border-slate-200">
<div className="flex items-center justify-between space-x-4 pb-4">
{isEditing ? (
<input
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
className="w-full rounded-md border border-dashed border-slate-300 bg-transparent px-2 py-1 text-3xl font-bold text-slate-800 focus:border-brand-dark focus:outline-none focus:ring-0"
placeholder="Dashboard name"
/>
) : (
<h1 className="border border-transparent px-2 py-1 text-3xl font-bold text-slate-800">{name}</h1>
)}
{children}
</div>
{isEditing ? (
<input
type="text"
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
className="mb-3 mt-1 w-full rounded-md border border-dashed border-slate-300 bg-transparent px-2 py-1 text-sm text-slate-500 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-0"
placeholder="Add a description..."
/>
) : description ? (
<p className="mb-3 mt-1 border border-transparent px-2 py-1 text-sm text-slate-500">{description}</p>
) : null}
</div>
);
}

View File

@@ -1,49 +1,6 @@
import { Delay } from "@suspensive/react";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageHeader } from "@/modules/ui/components/page-header";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import { DashboardControlBar } from "../components/dashboard-control-bar";
import { DashboardWidget } from "../components/dashboard-widget";
import { DashboardWidgetData } from "../components/dashboard-widget-data";
import { DashboardWidgetSkeleton } from "../components/dashboard-widget-skeleton";
import { DashboardDetailClient } from "../components/dashboard-detail-client";
import { executeWidgetQuery, getDashboard } from "../lib/data";
import { TDashboardWidget } from "../../types/analysis";
function getColSpan(w: number) {
if (w <= 2) return "col-span-12 md:col-span-2";
if (w <= 3) return "col-span-12 md:col-span-3";
if (w <= 4) return "col-span-12 md:col-span-4";
if (w <= 6) return "col-span-12 md:col-span-6";
if (w <= 8) return "col-span-12 md:col-span-8";
if (w <= 9) return "col-span-12 md:col-span-9";
return "col-span-12";
}
function StaticWidgetContent({ widget }: { widget: TDashboardWidget }) {
if (widget.type === "markdown") {
return (
<div className="prose prose-sm max-w-none">
<p className="text-gray-500">Markdown widget placeholder</p>
</div>
);
}
if (widget.type === "header") {
return (
<div className="flex h-full items-center">
<h2 className="text-2xl font-semibold text-gray-900">{widget.title || "Header"}</h2>
</div>
);
}
if (widget.type === "divider") {
return <div className="h-full w-full border-t border-gray-200" />;
}
return null;
}
export async function DashboardDetailPage({
params,
@@ -57,8 +14,6 @@ export async function DashboardDetailPage({
return notFound();
}
const isEmpty = dashboard.widgets.length === 0;
// Kick off all chart data queries in parallel (don't await -- let Suspense stream them)
const widgetDataPromises = new Map<
string,
@@ -71,53 +26,10 @@ export async function DashboardDetailPage({
}
return (
<div>
<GoBackButton url={`/environments/${environmentId}/analysis/dashboards`} />
<PageHeader
pageTitle={dashboard.name}
cta={<DashboardControlBar environmentId={environmentId} dashboard={dashboard} />}>
{dashboard.description && <p className="mt-2 text-sm text-gray-500">{dashboard.description}</p>}
</PageHeader>
<section className="pb-24 pt-6">
{isEmpty ? (
<div className="flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/50">
<div className="mb-4 rounded-full bg-gray-100 p-4">
<div className="h-12 w-12 rounded-md bg-gray-300 opacity-20" />
</div>
<h3 className="text-lg font-medium text-gray-900">No Data</h3>
<p className="mt-2 max-w-sm text-center text-gray-500">
There is currently no information to display. Add charts to build your dashboard.
</p>
<CreateChartButton environmentId={environmentId} />
</div>
) : (
<div className="grid grid-cols-12 gap-6">
{dashboard.widgets.map((widget) => (
<div key={widget.id} className={getColSpan(widget.layout.w)}>
{widget.type === "chart" && widget.chart ? (
<DashboardWidget title={widget.title || widget.chart.name || "Widget"}>
<Suspense
fallback={
<Delay ms={200}>
<DashboardWidgetSkeleton />
</Delay>
}>
<DashboardWidgetData
dataPromise={widgetDataPromises.get(widget.id)!}
chartType={widget.chart.type}
/>
</Suspense>
</DashboardWidget>
) : (
<DashboardWidget title={widget.title || "Widget"}>
<StaticWidgetContent widget={widget} />
</DashboardWidget>
)}
</div>
))}
</div>
)}
</section>
</div>
<DashboardDetailClient
environmentId={environmentId}
dashboard={dashboard}
widgetDataPromises={widgetDataPromises}
/>
);
}

View File

@@ -47,7 +47,7 @@ const ChartContainer = React.forwardRef<
data-chart={chartId}
ref={ref}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}>

View File

@@ -7,6 +7,8 @@ interface IconAction {
tooltip: string;
onClick?: () => void;
isVisible?: boolean;
disabled?: boolean;
isLoading?: boolean;
}
interface IconBarProps {
@@ -32,6 +34,8 @@ export const IconBar = ({ actions }: IconBarProps) => {
className="border-none hover:bg-slate-50"
size="icon"
onClick={action.onClick}
disabled={action.disabled}
loading={action.isLoading}
aria-label={action.tooltip}>
<action.icon />
</Button>

View File

@@ -123,6 +123,7 @@
"react-confetti": "6.4.0",
"react-day-picker": "9.6.7",
"react-dom": "19.2.3",
"react-grid-layout": "2.2.2",
"react-hook-form": "7.56.2",
"react-hot-toast": "2.5.2",
"react-i18next": "15.7.3",

51
pnpm-lock.yaml generated
View File

@@ -439,6 +439,9 @@ importers:
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
react-grid-layout:
specifier: 2.2.2
version: 2.2.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-hook-form:
specifier: 7.56.2
version: 7.56.2(react@19.2.3)
@@ -8029,6 +8032,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-equals@4.0.3:
resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==}
fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
@@ -10251,6 +10257,12 @@ packages:
peerDependencies:
react: ^19.2.3
react-draggable@4.5.0:
resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
react-email@5.2.5:
resolution: {integrity: sha512-YaCp5n/0czviN4lFndsYongiI0IJOMFtFoRVIPJc9+WPJejJEvzJO94r31p3Cz9swDuV0RhEhH1W0lJFAXntHA==}
engines: {node: '>=20.0.0'}
@@ -10261,6 +10273,12 @@ packages:
peerDependencies:
react: '>=16.13.1'
react-grid-layout@2.2.2:
resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
react-hook-form@7.56.2:
resolution: {integrity: sha512-vpfuHuQMF/L6GpuQ4c3ZDo+pRYxIi40gQqsCmmfUBwm+oqvBhKhwghCuj2o00YCgSfU6bR9KC/xnQGWm3Gr08A==}
engines: {node: '>=18.0.0'}
@@ -10336,6 +10354,12 @@ packages:
'@types/react':
optional: true
react-resizable@3.1.3:
resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==}
peerDependencies:
react: '>= 16.3'
react-dom: '>= 16.3'
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -21070,6 +21094,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-equals@4.0.3: {}
fast-glob@3.3.1:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -23380,6 +23406,13 @@ snapshots:
react: 19.2.3
scheduler: 0.27.0
react-draggable@4.5.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
clsx: 2.1.1
prop-types: 15.8.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-email@5.2.5:
dependencies:
'@babel/parser': 7.28.5
@@ -23409,6 +23442,17 @@ snapshots:
'@babel/runtime': 7.28.4
react: 19.2.3
react-grid-layout@2.2.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
clsx: 2.1.1
fast-equals: 4.0.3
prop-types: 15.8.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-draggable: 4.5.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-resizable: 3.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
resize-observer-polyfill: 1.5.1
react-hook-form@7.56.2(react@19.2.3):
dependencies:
react: 19.2.3
@@ -23485,6 +23529,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.1
react-resizable@3.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
prop-types: 15.8.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-draggable: 4.5.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react-style-singleton@2.2.3(@types/react@19.2.1)(react@19.2.1):
dependencies:
get-nonce: 1.0.1