mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-19 00:51:06 -06:00
refactor: chart list cleanups
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { ChartsListSkeleton } from "@/modules/ee/analysis/components/charts-list-skeleton";
|
||||
|
||||
export default function ChartsListLoading() {
|
||||
return <ChartsListSkeleton />;
|
||||
}
|
||||
@@ -1,3 +1 @@
|
||||
import { ChartsListPage } from "@/modules/ee/analysis/pages/charts-list-page";
|
||||
|
||||
export default ChartsListPage;
|
||||
export { ChartsListPage as default } from "@/modules/ee/analysis/pages/charts-list-page";
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"styling": "Styling",
|
||||
"submit": "Submit",
|
||||
"summary": "Summary",
|
||||
"chart": "Chart",
|
||||
"survey": "Survey",
|
||||
"survey_completed": "Survey completed.",
|
||||
"survey_id": "Survey ID",
|
||||
@@ -619,6 +620,15 @@
|
||||
"subtitle": "It takes less than 4 minutes.",
|
||||
"waiting_for_your_signal": "Waiting for your signal…"
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"chart_deleted_successfully": "Chart deleted successfully",
|
||||
"chart_duplicated_successfully": "Chart duplicated successfully",
|
||||
"chart_duplication_error": "Failed to duplicate chart",
|
||||
"delete_chart_confirmation": "Are you sure you want to delete this chart? This action cannot be undone.",
|
||||
"open_options": "Open options"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"add_attribute": "Add Attribute",
|
||||
"attribute_created_successfully": "Attribute created successfully",
|
||||
|
||||
@@ -143,6 +143,68 @@ export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAc
|
||||
)
|
||||
);
|
||||
|
||||
const ZDuplicateChartAction = z.object({
|
||||
environmentId: ZId,
|
||||
chartId: ZId,
|
||||
});
|
||||
|
||||
export const duplicateChartAction = authenticatedActionClient.schema(ZDuplicateChartAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"chart",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDuplicateChartAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sourceChart = await prisma.chart.findFirst({
|
||||
where: { id: parsedInput.chartId, projectId },
|
||||
});
|
||||
|
||||
if (!sourceChart) {
|
||||
throw new Error("Chart not found");
|
||||
}
|
||||
|
||||
const duplicatedChart = await prisma.chart.create({
|
||||
data: {
|
||||
name: `${sourceChart.name} (copy)`,
|
||||
type: sourceChart.type,
|
||||
projectId,
|
||||
query: sourceChart.query as object,
|
||||
config: (sourceChart.config as object) || {},
|
||||
createdBy: ctx.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.newObject = duplicatedChart;
|
||||
return duplicatedChart;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// --- Dashboard widget actions ---
|
||||
|
||||
const ZAddChartToDashboardAction = z.object({
|
||||
|
||||
@@ -4,7 +4,6 @@ import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -13,25 +12,25 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { deleteChartAction } from "../actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { deleteChartAction, duplicateChartAction } from "../actions";
|
||||
import { TChart } from "../types/analysis";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface ChartDropdownMenuProps {
|
||||
environmentId: string;
|
||||
chart: TChart;
|
||||
disabled?: boolean;
|
||||
deleteChart: (chartId: string) => void;
|
||||
onEdit?: () => void;
|
||||
onEdit?: (chartId: string) => void;
|
||||
}
|
||||
|
||||
export const ChartDropdownMenu = ({
|
||||
environmentId,
|
||||
chart,
|
||||
disabled,
|
||||
deleteChart,
|
||||
onEdit,
|
||||
}: ChartDropdownMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
@@ -41,33 +40,64 @@ export const ChartDropdownMenu = ({
|
||||
try {
|
||||
const result = await deleteChartAction({ environmentId, chartId });
|
||||
if (result?.data) {
|
||||
deleteChart(chartId);
|
||||
toast.success("Chart deleted successfully");
|
||||
toast.success(t("environments.analysis.charts.chart_deleted_successfully"));
|
||||
setDeleteDialogOpen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result?.serverError || "Failed to delete chart");
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Error deleting chart");
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
setTimeout(() => setIsDropDownOpen(false), 0);
|
||||
};
|
||||
|
||||
const handleDuplicateChart = async () => {
|
||||
closeDropdown();
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await duplicateChartAction({ environmentId, chartId: chart.id });
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.analysis.charts.chart_duplicated_successfully"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result) || t("environments.analysis.charts.chart_duplication_error"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.charts.chart_duplication_error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
closeDropdown();
|
||||
setTimeout(() => onEdit?.(chart.id), 0);
|
||||
};
|
||||
|
||||
const handleOpenDeleteDialog = () => {
|
||||
closeDropdown();
|
||||
setTimeout(() => setDeleteDialogOpen(true), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`${chart.name.toLowerCase().split(" ").join("-")}-chart-actions`}
|
||||
data-testid="chart-dropdown-menu">
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-white p-2",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||
)}
|
||||
<DropdownMenuTrigger className="z-10" asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-2"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<span className="sr-only">Open options</span>
|
||||
<span className="sr-only">{t("environments.analysis.charts.open_options")}</span>
|
||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="inline-block w-auto min-w-max">
|
||||
<DropdownMenuGroup>
|
||||
@@ -77,8 +107,7 @@ export const ChartDropdownMenu = ({
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onEdit?.();
|
||||
handleEdit();
|
||||
}}>
|
||||
<SquarePenIcon className="mr-2 size-4" />
|
||||
{t("common.edit")}
|
||||
@@ -89,11 +118,11 @@ export const ChartDropdownMenu = ({
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
toast.success("Duplicate functionality coming soon");
|
||||
}}>
|
||||
handleDuplicateChart();
|
||||
}}
|
||||
disabled={loading}>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.duplicate")}
|
||||
</button>
|
||||
@@ -105,8 +134,7 @@ export const ChartDropdownMenu = ({
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setDeleteDialogOpen(true);
|
||||
handleOpenDeleteDialog();
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
@@ -117,11 +145,11 @@ export const ChartDropdownMenu = ({
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat="Chart"
|
||||
deleteWhat={t("common.chart")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={() => handleDeleteChart(chart.id)}
|
||||
text="Are you sure you want to delete this chart? This action cannot be undone."
|
||||
text={t("environments.analysis.charts.delete_chart_confirmation")}
|
||||
isDeleting={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,49 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
ActivityIcon,
|
||||
AreaChartIcon,
|
||||
BarChart3Icon,
|
||||
LineChartIcon,
|
||||
MapIcon,
|
||||
PieChartIcon,
|
||||
ScatterChart,
|
||||
TableIcon,
|
||||
} from "lucide-react";
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { TChart, TDashboard } from "../types/analysis";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CHART_TYPE_ICONS } from "../lib/chart-types";
|
||||
import { TChart } from "../types/analysis";
|
||||
import { ChartDropdownMenu } from "./chart-dropdown-menu";
|
||||
import { CreateChartDialog } from "./create-chart-dialog";
|
||||
|
||||
interface ChartsListClientProps {
|
||||
interface ChartsListProps {
|
||||
charts: TChart[];
|
||||
dashboards: TDashboard[];
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
const CHART_TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
area: AreaChartIcon,
|
||||
bar: BarChart3Icon,
|
||||
line: LineChartIcon,
|
||||
pie: PieChartIcon,
|
||||
table: TableIcon,
|
||||
big_number: ActivityIcon,
|
||||
big_number_total: ActivityIcon,
|
||||
scatter: ScatterChart,
|
||||
map: MapIcon,
|
||||
};
|
||||
|
||||
export function ChartsListClient({ charts: initialCharts, environmentId }: ChartsListClientProps) {
|
||||
const [charts, setCharts] = useState(initialCharts);
|
||||
export function ChartsList({ charts, environmentId }: Readonly<ChartsListProps>) {
|
||||
const [editingChartId, setEditingChartId] = useState<string | undefined>(undefined);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const filteredCharts = charts;
|
||||
|
||||
const deleteChart = (chartId: string) => {
|
||||
setCharts(charts.filter((c) => c.id !== chartId));
|
||||
};
|
||||
|
||||
const getChartIcon = (type: string) => {
|
||||
const IconComponent = CHART_TYPE_ICONS[type] || BarChart3Icon;
|
||||
@@ -63,10 +39,10 @@ export function ChartsListClient({ charts: initialCharts, environmentId }: Chart
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">Title</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">Created By</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">Created</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">Updated</div>
|
||||
<div className="col-span-3 pl-6">{t("common.title")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_at")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.updated_at")}</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
{filteredCharts.length === 0 ? (
|
||||
@@ -109,9 +85,8 @@ export function ChartsListClient({ charts: initialCharts, environmentId }: Chart
|
||||
<ChartDropdownMenu
|
||||
environmentId={environmentId}
|
||||
chart={chart}
|
||||
deleteChart={deleteChart}
|
||||
onEdit={() => {
|
||||
setEditingChartId(chart.id);
|
||||
onEdit={(chartId) => {
|
||||
setEditingChartId(chartId);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
@@ -1,3 +1,4 @@
|
||||
import type React from "react";
|
||||
import {
|
||||
ActivityIcon,
|
||||
AreaChartIcon,
|
||||
@@ -19,3 +20,7 @@ export const CHART_TYPES = [
|
||||
{ id: "scatter", name: "Scatter Plot", icon: ScatterChart },
|
||||
{ id: "map", name: "World Map", icon: MapIcon },
|
||||
] as const;
|
||||
|
||||
export const CHART_TYPE_ICONS = Object.fromEntries(
|
||||
CHART_TYPES.map(({ id, icon }) => [id, icon])
|
||||
) as Record<string, React.ComponentType<{ className?: string }>>;
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { Suspense } from "react";
|
||||
import { ChartsListClient } from "../components/charts-list-client";
|
||||
import { ChartsListSkeleton } from "../components/charts-list-skeleton";
|
||||
import { getCharts, getDashboards } from "../lib/data";
|
||||
import { ChartsList } from "../components/charts-list";
|
||||
import { getCharts } from "../lib/data";
|
||||
|
||||
async function ChartsListContent({ environmentId }: { environmentId: string }) {
|
||||
const [charts, dashboards] = await Promise.all([getCharts(environmentId), getDashboards(environmentId)]);
|
||||
return <ChartsListClient charts={charts} dashboards={dashboards} environmentId={environmentId} />;
|
||||
interface ChartsListPageProps {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
}
|
||||
|
||||
export async function ChartsListPage({ params }: { params: Promise<{ environmentId: string }> }) {
|
||||
export async function ChartsListPage({ params }: Readonly<ChartsListPageProps>) {
|
||||
const { environmentId } = await params;
|
||||
const charts = await getCharts(environmentId);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<ChartsListSkeleton />}>
|
||||
<ChartsListContent environmentId={environmentId} />
|
||||
</Suspense>
|
||||
);
|
||||
return <ChartsList charts={charts} environmentId={environmentId} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user