Skip to content

Commit 32f4bf4

Browse files
N2D4cursoragent
andcommitted
Extract shared analytics utilities to reduce code duplication
- Create shared.tsx with common types, utilities, and components - Export: RowData, ConfigFolder, FolderWithId types - Export: isDateValue, isJsonValue, parseClickHouseDate utilities - Export: JsonValue, CellValue, RowDetailDialog, VirtualizedFlatTable, ErrorDisplay components - Update queries/page-client.tsx to use shared imports - Update tables/page-client.tsx to use shared imports (keeping local DateValue with context) - Update run-query.tsx to use shared imports (keeping local DateValue with relative time) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b405cd5 commit 32f4bf4

5 files changed

Lines changed: 373 additions & 565 deletions

File tree

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx

Lines changed: 21 additions & 267 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { Textarea } from "@/components/ui/textarea";
1616
import { useUpdateConfig } from "@/lib/config-update";
1717
import { cn } from "@/lib/utils";
1818
import {
19-
ArrowClockwiseIcon,
2019
CaretDownIcon,
2120
CaretRightIcon,
2221
CheckCircleIcon,
@@ -28,279 +27,34 @@ import {
2827
PlusIcon,
2928
SpinnerGapIcon,
3029
TrashIcon,
31-
WarningCircleIcon
3230
} from "@phosphor-icons/react";
3331
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
3432
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
35-
import { useVirtualizer } from "@tanstack/react-virtual";
36-
import { useCallback, useMemo, useRef, useState } from "react";
33+
import { useCallback, useMemo, useState } from "react";
3734
import { AppEnabledGuard } from "../../app-enabled-guard";
3835
import { PageLayout } from "../../page-layout";
3936
import { useAdminApp } from "../../use-admin-app";
40-
41-
type RowData = Record<string, unknown>;
42-
43-
type ConfigFolder = {
44-
displayName: string,
45-
sortOrder?: number,
46-
queries: Record<string, {
47-
displayName: string,
48-
sqlQuery: string,
49-
description?: string,
50-
}>,
51-
};
52-
53-
type FolderWithId = {
54-
id: string,
55-
displayName: string,
56-
sortOrder: number,
57-
queries: Array<{
58-
id: string,
59-
displayName: string,
60-
sqlQuery: string,
61-
description?: string,
62-
}>,
63-
};
64-
65-
// Detect if a value is a date string
66-
function isDateValue(value: unknown): value is string {
67-
if (typeof value !== "string") return false;
68-
return /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?/.test(value);
69-
}
70-
71-
// Detect if a value is JSON
72-
function isJsonValue(value: unknown): boolean {
73-
return typeof value === "object" && value !== null;
74-
}
75-
76-
// Parse ClickHouse date string as UTC
77-
function parseClickHouseDate(value: string): Date {
78-
const trimmed = value.trim();
79-
// Handle date-only strings (YYYY-MM-DD) by appending time
80-
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
81-
return new Date(trimmed + "T00:00:00Z");
82-
}
83-
const normalized = trimmed.replace(" ", "T") + (trimmed.includes("Z") || trimmed.includes("+") ? "" : "Z");
84-
return new Date(normalized);
85-
}
86-
87-
// Component for displaying JSON values
88-
function JsonValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
89-
const formatted = JSON.stringify(value, null, 2);
90-
const preview = JSON.stringify(value);
91-
92-
if (truncate && preview.length > 60) {
93-
return (
94-
<SimpleTooltip tooltip={<pre className="text-xs max-w-md overflow-auto max-h-64">{formatted}</pre>}>
95-
<span className="cursor-help text-muted-foreground">
96-
{preview.slice(0, 57)}...
97-
</span>
98-
</SimpleTooltip>
99-
);
100-
}
101-
102-
return <span className="text-muted-foreground">{preview}</span>;
103-
}
104-
105-
// Format a cell value for display
106-
function CellValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
107-
if (value === null || value === undefined) {
108-
return <span className="text-muted-foreground/50"></span>;
109-
}
110-
111-
if (isDateValue(value)) {
112-
const date = parseClickHouseDate(value);
113-
return <span>{date.toLocaleString()}</span>;
114-
}
115-
116-
if (isJsonValue(value)) {
117-
return <JsonValue value={value} truncate={truncate} />;
118-
}
119-
120-
const str = String(value);
121-
if (truncate && str.length > 100) {
122-
return (
123-
<SimpleTooltip tooltip={str}>
124-
<span className="cursor-help">{str.slice(0, 97)}...</span>
125-
</SimpleTooltip>
126-
);
127-
}
128-
129-
return <span>{str}</span>;
130-
}
131-
132-
// Row detail dialog
133-
function RowDetailDialog({
134-
row,
135-
columns,
136-
open,
137-
onOpenChange,
138-
}: {
139-
row: RowData | null,
140-
columns: string[],
141-
open: boolean,
142-
onOpenChange: (open: boolean) => void,
143-
}) {
144-
if (!row) return null;
145-
146-
return (
147-
<Dialog open={open} onOpenChange={onOpenChange}>
148-
<DialogContent className="max-w-2xl max-h-[80vh]">
149-
<DialogHeader>
150-
<DialogTitle>Row Details</DialogTitle>
151-
</DialogHeader>
152-
<DialogBody>
153-
<div className="space-y-4">
154-
{columns.map((column) => (
155-
<div key={column} className="space-y-1">
156-
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
157-
{column}
158-
</Label>
159-
<div className="font-mono text-sm bg-muted/30 rounded px-3 py-2 overflow-auto max-h-48">
160-
{isJsonValue(row[column]) ? (
161-
<pre className="whitespace-pre-wrap break-all">
162-
{JSON.stringify(row[column], null, 2)}
163-
</pre>
164-
) : (
165-
<CellValue value={row[column]} truncate={false} />
166-
)}
167-
</div>
168-
</div>
169-
))}
170-
</div>
171-
</DialogBody>
172-
</DialogContent>
173-
</Dialog>
174-
);
175-
}
176-
177-
// Virtualized flat table component
178-
function VirtualizedFlatTable({
179-
columns,
180-
rows,
181-
onRowClick,
182-
}: {
183-
columns: string[],
184-
rows: RowData[],
185-
onRowClick: (row: RowData) => void,
186-
}) {
187-
const parentRef = useRef<HTMLDivElement>(null);
188-
189-
const rowVirtualizer = useVirtualizer({
190-
count: rows.length,
191-
getScrollElement: () => parentRef.current,
192-
estimateSize: () => 36,
193-
overscan: 10,
194-
});
195-
196-
// Column widths - distribute based on content type
197-
const columnWidths = useMemo(() => {
198-
const widths = new Map<string, string>();
199-
columns.forEach((col) => {
200-
if (col.includes("id") && col !== "project_id") {
201-
widths.set(col, "minmax(200px, 1fr)");
202-
} else if (col.includes("_at") || col.includes("date")) {
203-
widths.set(col, "minmax(100px, 140px)");
204-
} else if (col === "data" || col.includes("json")) {
205-
widths.set(col, "minmax(180px, 2fr)");
206-
} else if (col === "event_type" || col === "type") {
207-
widths.set(col, "minmax(100px, 160px)");
208-
} else {
209-
widths.set(col, "minmax(80px, 1fr)");
210-
}
211-
});
212-
return widths;
213-
}, [columns]);
214-
215-
const gridTemplateColumns = columns.map((col) => columnWidths.get(col) ?? "1fr").join(" ");
216-
const minContentWidth = columns.length * 120;
217-
218-
return (
219-
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
220-
<div
221-
ref={parentRef}
222-
className="flex-1 overflow-auto"
223-
>
224-
<div style={{ minWidth: `${minContentWidth}px` }}>
225-
{/* Sticky header */}
226-
<div
227-
className="grid gap-3 px-3 py-1.5 border-b border-border/50 bg-muted/40 backdrop-blur-sm sticky top-0 z-10"
228-
style={{ gridTemplateColumns }}
229-
>
230-
{columns.map((column) => (
231-
<span
232-
key={column}
233-
className="font-mono text-xs font-medium text-muted-foreground"
234-
>
235-
{column}
236-
</span>
237-
))}
238-
</div>
239-
240-
{/* Virtualized rows container */}
241-
<div
242-
style={{
243-
height: `${rowVirtualizer.getTotalSize()}px`,
244-
width: "100%",
245-
position: "relative",
246-
}}
247-
>
248-
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
249-
const row = rows[virtualRow.index];
250-
251-
return (
252-
<div
253-
key={virtualRow.index}
254-
className={cn(
255-
"absolute left-0 right-0 grid gap-3 px-3 items-center cursor-pointer",
256-
"border-b border-border/30 hover:bg-muted/30 transition-colors hover:transition-none",
257-
virtualRow.index % 2 === 0 ? "bg-transparent" : "bg-muted/10"
258-
)}
259-
style={{
260-
top: `${virtualRow.start}px`,
261-
height: `${virtualRow.size}px`,
262-
gridTemplateColumns,
263-
}}
264-
onClick={() => onRowClick(row)}
265-
>
266-
{columns.map((column) => (
267-
<div key={column} className="font-mono text-[11px] truncate">
268-
<CellValue value={row[column]} />
269-
</div>
270-
))}
271-
</div>
272-
);
273-
})}
274-
</div>
275-
</div>
276-
</div>
277-
</div>
278-
);
279-
}
280-
281-
// Error display component
282-
function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () => void | Promise<void> }) {
283-
const message = error instanceof Error ? error.message : String(error);
284-
37+
import {
38+
ConfigFolder,
39+
ErrorDisplay,
40+
FolderWithId,
41+
RowData,
42+
RowDetailDialog,
43+
VirtualizedFlatTable,
44+
} from "../shared";
45+
46+
// Delete icon button for sidebar items
47+
function DeleteIconButton({ onClick }: { onClick: () => void }) {
28548
return (
286-
<div className="flex flex-col items-center justify-center gap-4 p-6 text-center">
287-
<div className="w-14 h-14 rounded-2xl bg-red-500/10 flex items-center justify-center">
288-
<WarningCircleIcon className="h-7 w-7 text-red-500" />
289-
</div>
290-
<div>
291-
<h3 className="text-sm font-semibold text-foreground mb-1">Query Error</h3>
292-
<p className="text-xs text-muted-foreground max-w-md break-words font-mono whitespace-pre-wrap">
293-
{message}
294-
</p>
295-
</div>
296-
<button
297-
onClick={() => runAsynchronouslyWithAlert(onRetry)}
298-
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium bg-foreground/[0.06] hover:bg-foreground/[0.1] transition-colors hover:transition-none"
299-
>
300-
<ArrowClockwiseIcon className="h-3 w-3" />
301-
Retry
302-
</button>
303-
</div>
49+
<button
50+
onClick={(e) => {
51+
e.stopPropagation();
52+
onClick();
53+
}}
54+
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-red-500/20 transition-colors hover:transition-none"
55+
>
56+
<TrashIcon className="h-3 w-3 text-red-500" />
57+
</button>
30458
);
30559
}
30660

0 commit comments

Comments
 (0)