Skip to content

Commit e63f509

Browse files
authored
Project Analytics Config Page (#93)
Resolves #88 - Add an Analytics entry to the project sidebar and a dedicated /projects/[projectId]/analytics page for CRUD operations on project's analytics config only. - No Analytics Configuration card when none exist, matching the style of the file and email config pages. - getAnalyticsConfig returns null only on 404; throws 401 for auth failures and shows an error alert instead of looking like an empty state - Delete the config by the current projectId, not the table row id - Move the analytics table out of Settings; point the project dashboard card at the new page; add the 'Analytics' top bar title - Fix toast-during-render issues (move to useEffect hooks) - Use theme-based Alert variant='destructive' instead of hardcoded colors - Improve delete dialog copy and make empty-state card responsive
1 parent aa406fd commit e63f509

7 files changed

Lines changed: 175 additions & 68 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"use client";
2+
3+
import { AnalyticsConfigTable } from "@/components/analyticsConfigTable/analyticsConfig-table";
4+
import {
5+
Breadcrumb,
6+
BreadcrumbItem,
7+
BreadcrumbLink,
8+
BreadcrumbList,
9+
BreadcrumbPage,
10+
BreadcrumbSeparator,
11+
} from "@/components/ui/breadcrumb";
12+
import { Separator } from "@/components/ui/separator";
13+
import { getProjectById } from "@/lib/project";
14+
import { useQuery } from "@tanstack/react-query";
15+
import { ProjectResponse } from "juno-sdk/build/main/internal/index";
16+
import { useParams } from "next/navigation";
17+
import { useEffect } from "react";
18+
import { toast } from "sonner";
19+
20+
const ProjectAnalyticsPage = () => {
21+
const { projectId } = useParams<{ projectId: string }>();
22+
23+
const { isLoading, isError, data, error } = useQuery<ProjectResponse>({
24+
queryKey: ["project", projectId],
25+
queryFn: async () => {
26+
const result = await getProjectById(Number(projectId));
27+
if (!result.success) {
28+
throw new Error(result.error);
29+
}
30+
return result.project;
31+
},
32+
});
33+
34+
useEffect(() => {
35+
if (isError && error) {
36+
toast.error("Error", {
37+
description: `Failed to fetch project: ${error.message}`,
38+
});
39+
}
40+
}, [isError, error]);
41+
42+
return (
43+
<div className="flex flex-col">
44+
<Breadcrumb className="mb-4">
45+
<BreadcrumbList>
46+
<BreadcrumbItem>
47+
<BreadcrumbLink href="/projects">Projects</BreadcrumbLink>
48+
</BreadcrumbItem>
49+
<BreadcrumbSeparator />
50+
<BreadcrumbItem>
51+
<BreadcrumbLink href={`/projects/${projectId}`}>
52+
{isLoading ? "****" : (data?.name ?? "Unknown")}
53+
</BreadcrumbLink>
54+
</BreadcrumbItem>
55+
<BreadcrumbSeparator />
56+
<BreadcrumbItem>
57+
<BreadcrumbPage>Analytics</BreadcrumbPage>
58+
</BreadcrumbItem>
59+
</BreadcrumbList>
60+
</Breadcrumb>
61+
<Separator className="mb-8" />
62+
<div className="flex flex-col gap-8">
63+
<AnalyticsConfigTable projectId={projectId} />
64+
</div>
65+
</div>
66+
);
67+
};
68+
69+
export default ProjectAnalyticsPage;

‎src/app/(dashboard)/projects/[projectId]/page.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
} from "@/lib/settings";
3232
import { useQuery } from "@tanstack/react-query";
3333
import type { ProjectResponse } from "juno-sdk/build/main/internal/index";
34-
import { BarChart3, Settings } from "lucide-react";
34+
import { BarChart3 } from "lucide-react";
3535
import Link from "next/link";
3636
import { useParams } from "next/navigation";
3737
import { useEffect, useMemo, useState } from "react";
@@ -575,9 +575,9 @@ const DashboardPage = () => {
575575
clicks, and custom events using Juno.
576576
</p>
577577
<Button asChild>
578-
<Link href={`/projects/${projectId}/settings`}>
579-
<Settings className="mr-2 h-4 w-4" />
580-
Go to Settings
578+
<Link href={`/projects/${projectId}/analytics`}>
579+
<BarChart3 className="mr-2 h-4 w-4" />
580+
Set up analytics
581581
</Link>
582582
</Button>
583583
</CardContent>

‎src/app/(dashboard)/projects/[projectId]/settings/page.tsx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"use client";
22

3-
import { AnalyticsConfigTable } from "@/components/analyticsConfigTable/analyticsConfig-table";
43
import { EmailConfigTable } from "@/components/emailConfigTable/emailConfig-table";
54
import { FileConfigTable } from "@/components/fileConfigTable/fileConfig-table";
65
import {
@@ -61,7 +60,6 @@ const ProjectSettingsPage = () => {
6160
<div className="flex flex-col gap-8">
6261
<FileConfigTable projectId={projectId} />
6362
<EmailConfigTable projectId={projectId} />
64-
<AnalyticsConfigTable projectId={projectId} />
6563
</div>
6664
</div>
6765
);

‎src/components/analyticsConfigTable/analyticsConfig-table.tsx‎

Lines changed: 88 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import {
1212
getAnalyticsConfig,
1313
updateAnalyticsConfig,
1414
} from "@/lib/settings";
15+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1516
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
16-
import { useState } from "react";
17+
import { ChartColumn, CircleX } from "lucide-react";
18+
import { useEffect, useState } from "react";
1719
import { toast } from "sonner";
1820
import { useReadOnlyMode } from "../providers/SessionProvider";
1921
import { BaseTable } from "../baseTable";
2022
import AddAnalyticsConfigForm from "../forms/AddAnalyticsConfigForm";
23+
import { Alert } from "../ui/alert";
2124
import { Button } from "../ui/button";
2225
import { DialogFooter, DialogHeader } from "../ui/dialog";
2326
import { analyticsConfigColumns } from "./columns";
@@ -29,7 +32,6 @@ interface AnalyticsConfigTableProps {
2932
export function AnalyticsConfigTable({ projectId }: AnalyticsConfigTableProps) {
3033
const [isAddConfigDialogOpen, setIsAddConfigDialogOpen] = useState(false);
3134
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
32-
const [selectedRows, setSelectedRows] = useState([]);
3335
const isReadOnly = useReadOnlyMode();
3436

3537
const queryClient = useQueryClient();
@@ -41,21 +43,16 @@ export function AnalyticsConfigTable({ projectId }: AnalyticsConfigTableProps) {
4143

4244
const analyticsConfigRowData = [data].filter((config) => config);
4345

44-
if (isError) {
45-
toast.error("Error", {
46-
description: `Failed to fetch analytics config: ${error.message}`,
47-
});
48-
}
49-
50-
const deleteAnalyticsConfigHandler = useMutation({
51-
mutationFn: () => {
52-
const deletePromises = selectedRows.map(async (row) => {
53-
const projectId = row.id;
54-
return deleteAnalyticsConfig(projectId);
46+
useEffect(() => {
47+
if (isError && error) {
48+
toast.error("Error", {
49+
description: `Failed to fetch analytics config: ${error.message}`,
5550
});
51+
}
52+
}, [isError, error]);
5653

57-
return Promise.all(deletePromises);
58-
},
54+
const deleteAnalyticsConfigHandler = useMutation({
55+
mutationFn: async () => deleteAnalyticsConfig(projectId),
5956
onSuccess: () => {
6057
toast.success("Success", {
6158
description: `Successfully deleted analytics configs.`,
@@ -135,11 +132,11 @@ export function AnalyticsConfigTable({ projectId }: AnalyticsConfigTableProps) {
135132
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
136133
<DialogContent>
137134
<DialogHeader>
138-
<DialogTitle>Delete Selected Analytics Config</DialogTitle>
135+
<DialogTitle>Delete Analytics Configuration</DialogTitle>
139136
<DialogDescription>
140-
Are you sure you want to delete {selectedRows.length} selected
141-
config{selectedRows.length > 1 ? "s" : ""}? This action cannot be
142-
undone.
137+
Are you sure you want to delete the analytics configuration for
138+
this project? This will remove all analytics tracking settings and
139+
cannot be undone.
143140
</DialogDescription>
144141
</DialogHeader>
145142
<DialogFooter>
@@ -163,48 +160,79 @@ export function AnalyticsConfigTable({ projectId }: AnalyticsConfigTableProps) {
163160
</DialogContent>
164161
</Dialog>
165162
<h1 className="text-lg font-bold">Analytics Configurations</h1>
166-
<BaseTable
167-
data={analyticsConfigRowData}
168-
columns={analyticsConfigColumns(
169-
Number(projectId),
170-
addAnalyticsConfigHandler.isPending,
171-
(
172-
projectId: number,
173-
keys: {
174-
serverAnalyticsKey: string;
175-
clientAnalyticsKey: string;
163+
{isError && (
164+
<Alert variant="destructive">
165+
<CircleX className="h-4 w-4" />
166+
<span className="text-sm">
167+
Couldn&apos;t load analytics config: {error.message}
168+
</span>
169+
</Alert>
170+
)}
171+
{isError ? null : !isLoading && analyticsConfigRowData.length === 0 ? (
172+
<Card className="max-w-full sm:max-w-md">
173+
<CardHeader>
174+
<div className="flex items-center gap-3">
175+
<ChartColumn className="h-5 w-5 text-muted-foreground" />
176+
<CardTitle>No Analytics Configuration</CardTitle>
177+
</div>
178+
</CardHeader>
179+
<CardContent>
180+
<p className="text-sm text-muted-foreground mb-4">
181+
Project {projectId} has no analytics configuration yet. Add a
182+
server key and a client key to start tracking user interactions,
183+
visits, clicks, and custom events using Juno.
184+
</p>
185+
<Button
186+
onClick={() => setIsAddConfigDialogOpen(true)}
187+
disabled={isReadOnly || addAnalyticsConfigHandler.isPending}
188+
>
189+
Set up analytics
190+
</Button>
191+
</CardContent>
192+
</Card>
193+
) : (
194+
<BaseTable
195+
data={analyticsConfigRowData}
196+
columns={analyticsConfigColumns(
197+
Number(projectId),
198+
addAnalyticsConfigHandler.isPending,
199+
(
200+
projectId: number,
201+
keys: {
202+
serverAnalyticsKey: string;
203+
clientAnalyticsKey: string;
204+
},
205+
) => {
206+
const { serverAnalyticsKey, clientAnalyticsKey } = keys;
207+
updateAnalyticsConfigHandler.mutate({
208+
projectId,
209+
serverAnalyticsKey,
210+
clientAnalyticsKey,
211+
});
212+
setIsAddConfigDialogOpen(false);
176213
},
177-
) => {
178-
const { serverAnalyticsKey, clientAnalyticsKey } = keys;
179-
updateAnalyticsConfigHandler.mutate({
180-
projectId,
181-
serverAnalyticsKey,
182-
clientAnalyticsKey,
183-
});
184-
setIsAddConfigDialogOpen(false);
185-
},
186-
isReadOnly,
187-
)}
188-
isLoading={isLoading}
189-
filterParams={{
190-
placeholder: "Filter by environment...",
191-
filterColumn: "environment",
192-
}}
193-
onAddNewRow={() => {
194-
if (analyticsConfigRowData.length === 0) {
195-
setIsAddConfigDialogOpen(true);
196-
} else {
197-
toast.error("Error", {
198-
description:
199-
"Project can have at most 1 analytics configuration per environment",
200-
});
201-
}
202-
}}
203-
onDeleteRow={(rows) => {
204-
setSelectedRows(rows);
205-
setIsDeleteDialogOpen(true);
206-
}}
207-
/>
214+
isReadOnly,
215+
)}
216+
isLoading={isLoading}
217+
filterParams={{
218+
placeholder: "Filter by environment...",
219+
filterColumn: "environment",
220+
}}
221+
onAddNewRow={() => {
222+
if (analyticsConfigRowData.length === 0) {
223+
setIsAddConfigDialogOpen(true);
224+
} else {
225+
toast.error("Error", {
226+
description:
227+
"Project can have at most 1 analytics configuration per environment",
228+
});
229+
}
230+
}}
231+
onDeleteRow={() => {
232+
setIsDeleteDialogOpen(true);
233+
}}
234+
/>
235+
)}
208236
</div>
209237
);
210238
}

‎src/components/sidebar/project-sidebar.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Settings,
1111
Shield,
1212
User,
13+
ChartColumn,
1314
} from "lucide-react";
1415

1516
import {
@@ -135,6 +136,11 @@ export function ProjectSidebar({
135136
url: `/projects/${projectId}/settings`,
136137
icon: Settings,
137138
},
139+
{
140+
title: "Analytics",
141+
url: `/projects/${projectId}/analytics`,
142+
icon: ChartColumn,
143+
},
138144
];
139145

140146
return (

‎src/components/top-bar.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ function getPageTitle(pathname: string): string {
2222
if (sub === "/services/email") return "Email";
2323
if (sub === "/services/files") return "Files";
2424
if (sub === "/settings") return "Settings";
25+
if (sub === "/analytics") return "Analytics";
2526
}
2627

2728
return "Dashboard";

‎src/lib/settings.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export async function getEmailConfig(
126126

127127
export async function getAnalyticsConfig(
128128
projectId: string,
129-
): Promise<AnalyticsConfigResponse> {
129+
): Promise<AnalyticsConfigResponse | null> {
130130
const session = await getSession();
131131
if (!session) {
132132
throw new Error("Unauthorized");
@@ -147,9 +147,14 @@ export async function getAnalyticsConfig(
147147
);
148148
return JSON.parse(JSON.stringify(analyticsConfig));
149149
} catch (e) {
150-
if (e.response?.status === 404 || e.response?.status === 401) {
150+
if (e.response?.status === 404) {
151151
return null;
152152
}
153+
if (e.response?.status === 401) {
154+
throw new Error(
155+
`Unauthorized: Juno rejected the request for project ${projectId}. The API key may be missing, invalid, or not permitted for this project.`,
156+
);
157+
}
153158
throw e;
154159
}
155160
}

0 commit comments

Comments
 (0)