mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-15 11:41:29 -05:00
Compare commits
1 Commits
dashboards
...
dashboards
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e6a35b3d0 |
@@ -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",
|
||||
|
||||
@@ -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 "{chartType}" not yet supported
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
51
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user