From 08fc9affa2d2ae89331e41aaca53769fc887d7b2 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 23 May 2026 22:30:32 +0530 Subject: [PATCH 01/10] Add BigSetToaster component with theme support and toast notifications --- frontend/components/Toaster.tsx | 75 +++++++++++++++++++++++++++++++++ frontend/package.json | 2 + 2 files changed, 77 insertions(+) create mode 100644 frontend/components/Toaster.tsx diff --git a/frontend/components/Toaster.tsx b/frontend/components/Toaster.tsx new file mode 100644 index 0000000..e678e7c --- /dev/null +++ b/frontend/components/Toaster.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + CircleCheck, + Info, + Loader2, + OctagonX, + TriangleAlert, +} from "lucide-react"; +import { Toaster as Sonner, toast, type ToasterProps } from "sonner"; + +function useTheme() { + const [theme, setTheme] = useState<"light" | "dark">("light"); + + useEffect(() => { + const html = document.documentElement; + + function readTheme() { + const stored = localStorage.getItem("bigset:theme"); + const effective = + stored === "dark" || stored === "light" + ? stored + : window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + return effective as "light" | "dark"; + } + + setTheme(readTheme()); + + const observer = new MutationObserver(() => { + setTheme(readTheme()); + }); + observer.observe(html, { attributes: true, attributeFilter: ["data-theme"] }); + return () => observer.disconnect(); + }, []); + + return { theme }; +} + +function BigSetToaster({ ...props }: ToasterProps) { + const { theme } = useTheme(); + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--surface)", + "--normal-text": "var(--foreground)", + "--normal-border": "var(--border)", + "--normal-border-radius": "6px", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ); +} + +export { BigSetToaster, toast }; \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index d7d7a0c..6e2dcd0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,11 +12,13 @@ "@clerk/nextjs": "^7.3.7", "@tanstack/react-table": "^8.21.3", "convex": "^1.39.1", + "lucide-react": "^1.16.0", "next": "16.2.6", "posthog-js": "^1.374.2", "react": "19.2.4", "react-dom": "19.2.4", "react-window": "^1.8.11", + "sonner": "^2.0.7", "xlsx": "^0.18.5" }, "devDependencies": { From 6bbc37a7782ab7396a444a44988e658747026008 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 23 May 2026 22:31:15 +0530 Subject: [PATCH 02/10] Allow user to uodate an Dataset name , added validation and toast for UX --- frontend/convex/datasets.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/frontend/convex/datasets.ts b/frontend/convex/datasets.ts index f0fb8cc..e3dc586 100644 --- a/frontend/convex/datasets.ts +++ b/frontend/convex/datasets.ts @@ -1,4 +1,4 @@ -import { query, mutation } from "./_generated/server.js"; +import { query, mutation, internalQuery } from "./_generated/server.js"; import type { QueryCtx } from "./_generated/server.js"; import { v } from "convex/values"; import type { Doc } from "./_generated/dataModel.js"; @@ -83,6 +83,15 @@ export const get = query({ }, }); +export const getInternal = internalQuery({ + args: { id: v.id("datasets") }, + handler: async (ctx, args) => { + const dataset = await ctx.db.get(args.id); + if (!dataset) throw new Error("Dataset not found"); + return dataset; + }, +}); + export const create = mutation({ args: { name: v.string(), @@ -122,6 +131,24 @@ export const updateStatus = mutation({ }, }); +export const updateDetails = mutation({ + args: { + id: v.id("datasets"), + name: v.string(), + description: v.string(), + }, + handler: async (ctx, args) => { + const trimmedName = args.name.trim(); + if (!trimmedName) throw new Error("Dataset name cannot be empty"); + const dataset = await loadOwnedDataset(ctx, args.id); + if (trimmedName === dataset.name && args.description === dataset.description) return; + await ctx.db.patch(dataset._id, { + name: trimmedName, + description: args.description, + }); + }, +}); + export const remove = mutation({ args: { id: v.id("datasets") }, handler: async (ctx, args) => { From 154c5001cefc19ed122dbec9309f9eae478b5d2e Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 23 May 2026 22:31:42 +0530 Subject: [PATCH 03/10] Implement dataset name editing with validation and toast notifications --- frontend/app/dataset/[id]/page.tsx | 75 ++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index 3a158ae..7544219 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -3,7 +3,7 @@ import { useParams } from "next/navigation"; import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; -import { useQuery, useConvexAuth } from "convex/react"; +import { useQuery, useMutation, useConvexAuth } from "convex/react"; import { useAuth } from "@clerk/nextjs"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; @@ -14,6 +14,7 @@ import { StatusBadge } from "@/components/dataset/StatusBadge"; import { downloadCSV, downloadXLSX } from "@/lib/export"; import { populate } from "@/lib/backend"; import { EVENTS, captureException, track } from "@/lib/analytics"; +import { toast } from "@/components/Toaster"; export default function DatasetPage() { const params = useParams(); @@ -21,6 +22,11 @@ export default function DatasetPage() { const { userId, getToken } = useAuth(); const [exporting, setExporting] = useState<"csv" | "xlsx" | null>(null); const [populating, setPopulating] = useState(false); + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(""); + const nameInputRef = useRef(null); + + const updateDetails = useMutation(api.datasets.updateDetails); const datasetId = params.id as Id<"datasets">; const dataset = useQuery( @@ -120,6 +126,38 @@ export default function DatasetPage() { } } + function startEditingName() { + if (!dataset) return; + setNameValue(dataset.name); + setEditingName(true); + setTimeout(() => nameInputRef.current?.select(), 0); + } + + async function saveName() { + if (!dataset) return; + const trimmed = nameValue.trim(); + if (!trimmed || trimmed === dataset.name) { + setEditingName(false); + return; + } + try { + await updateDetails({ + id: dataset._id, + name: trimmed, + description: dataset.description, + }); + toast.success("Dataset name updated"); + } catch { + toast.error("Failed to update dataset name"); + } finally { + setEditingName(false); + } + } + + function cancelNameEdit() { + setEditingName(false); + } + if (authLoading || dataset === undefined || rows === undefined) { return (
@@ -155,9 +193,38 @@ export default function DatasetPage() { BigSet / -

- {dataset.name} -

+ {editingName ? ( + setNameValue(e.target.value)} + onBlur={saveName} + onKeyDown={(e) => { + if (e.key === "Enter") saveName(); + if (e.key === "Escape") cancelNameEdit(); + }} + className="text-sm font-semibold tracking-tight truncate max-w-md rounded border border-border bg-background px-2 py-0.5 outline-none focus:border-foreground/30" + /> + ) : ( + + )}
From 5ca416cb4d33a3ca30bbb45037e8bb7c7d093b44 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 23 May 2026 22:31:53 +0530 Subject: [PATCH 04/10] Add BigSetToaster component to RootLayout for enhanced notifications --- frontend/app/layout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index c4955a3..8bdd4f8 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { ClerkProvider } from "@clerk/nextjs"; import { ConvexClientProvider } from "./convex-provider"; +import { BigSetToaster } from "@/components/Toaster"; import { AnalyticsProvider } from "@/lib/analytics-provider"; import "./globals.css"; @@ -52,6 +53,7 @@ export default function RootLayout({ > {children} + From a6ffc3155600264ae329f1eed91ab017df079dd1 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 23 May 2026 23:03:47 +0530 Subject: [PATCH 05/10] Add SideSheet component for cell detail display and implement cell expansion functionality --- frontend/app/dataset/[id]/page.tsx | 29 +++++ frontend/app/globals.css | 9 ++ frontend/components/SideSheet.tsx | 120 +++++++++++++++++++++ frontend/components/table/DataRow.tsx | 26 +++-- frontend/components/table/DatasetTable.tsx | 5 +- 5 files changed, 178 insertions(+), 11 deletions(-) create mode 100644 frontend/components/SideSheet.tsx diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index 7544219..f3b06f9 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -8,9 +8,11 @@ import { useAuth } from "@clerk/nextjs"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { DatasetTable } from "@/components/table"; +import type { DatasetColumn } from "@/components/table/types"; import { useSelection } from "@/components/table/use-selection"; import { ThemeToggle } from "@/components/ThemeToggle"; import { StatusBadge } from "@/components/dataset/StatusBadge"; +import { SideSheet, CellDetail } from "@/components/SideSheet"; import { downloadCSV, downloadXLSX } from "@/lib/export"; import { populate } from "@/lib/backend"; import { EVENTS, captureException, track } from "@/lib/analytics"; @@ -25,6 +27,11 @@ export default function DatasetPage() { const [editingName, setEditingName] = useState(false); const [nameValue, setNameValue] = useState(""); const nameInputRef = useRef(null); + const [sideSheet, setSideSheet] = useState<{ + column: DatasetColumn; + value: unknown; + rowId: string; + } | null>(null); const updateDetails = useMutation(api.datasets.updateDetails); @@ -158,6 +165,13 @@ export default function DatasetPage() { setEditingName(false); } + function handleCellExpand(columnName: string, value: unknown, rowId: string) { + if (!dataset) return; + const column = dataset.columns.find((c) => c.name === columnName); + if (!column) return; + setSideSheet({ column, value, rowId }); + } + if (authLoading || dataset === undefined || rows === undefined) { return (
@@ -291,7 +305,22 @@ export default function DatasetPage() { rows={rows} datasetId={datasetId} selection={selection} + onCellExpand={handleCellExpand} /> + + setSideSheet(null)} + > + {sideSheet && ( + + )} +
); } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index e62cd07..bf8853b 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -90,3 +90,12 @@ body { :root[data-theme="dark"] .group:hover .card-glow::before { opacity: 0.45; } + +@keyframes slide-in { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +.animate-slide-in { + animation: slide-in 0.3s ease-out; +} diff --git a/frontend/components/SideSheet.tsx b/frontend/components/SideSheet.tsx new file mode 100644 index 0000000..e3b3fc4 --- /dev/null +++ b/frontend/components/SideSheet.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Copy, X } from "lucide-react"; +import { toast } from "@/components/Toaster"; + +interface SideSheetProps { + open: boolean; + onClose: () => void; + children: React.ReactNode; +} + +export function SideSheet({ open, onClose, children }: SideSheetProps) { + const panelRef = useRef(null); + + useEffect(() => { + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [open]); + + if (!open) return null; + + return ( +
+
+
+
+

Cell Details

+ +
+
{children}
+
+
+ ); +} + +interface CellDetailProps { + columnName: string; + columnType: string; + description?: string; + value: unknown; +} + +export function CellDetail({ + columnName, + columnType, + description, + value, +}: CellDetailProps) { + const displayValue = + value == null || value === "" ? "—" : String(value); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(displayValue); + toast.success("Copied to clipboard"); + } catch { + toast.error("Failed to copy"); + } + } + + return ( +
+
+ +

{columnName}

+ {description && ( +

{description}

+ )} +
+ +
+

+ Type +

+

{columnType}

+
+ +
+
+

+ Value +

+ +
+
+

+ {displayValue} +

+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/table/DataRow.tsx b/frontend/components/table/DataRow.tsx index b93c512..4864f1b 100644 --- a/frontend/components/table/DataRow.tsx +++ b/frontend/components/table/DataRow.tsx @@ -6,6 +6,7 @@ import type { Row } from "@tanstack/react-table"; import type { DatasetRow, DatasetColumn } from "./types"; import { CellValue } from "./CellValue"; import { floorWidth } from "./utils"; +import { Maximize2 } from "lucide-react"; export interface DataRowData { rows: Row[]; @@ -13,6 +14,7 @@ export interface DataRowData { columnWidths: number[]; isSelected: (id: string) => boolean; toggleRow: (id: string, shiftKey: boolean) => void; + onCellExpand: (columnName: string, value: unknown, rowId: string) => void; } function DataRowImpl({ @@ -24,7 +26,7 @@ function DataRowImpl({ index: number; style: CSSProperties; }) { - const { rows, columns, columnWidths, isSelected, toggleRow } = data; + const { rows, columns, columnWidths, isSelected, toggleRow, onCellExpand } = data; const row = rows[index]; if (!row) { @@ -65,16 +67,9 @@ function DataRowImpl({ return (
+
); })} diff --git a/frontend/components/table/DatasetTable.tsx b/frontend/components/table/DatasetTable.tsx index 692226e..cc061fe 100644 --- a/frontend/components/table/DatasetTable.tsx +++ b/frontend/components/table/DatasetTable.tsx @@ -60,11 +60,13 @@ export function DatasetTable({ rows, datasetId, selection, + onCellExpand, }: { dataset: DatasetMeta; rows: DatasetRow[]; datasetId: string; selection: Selection; + onCellExpand: (columnName: string, value: unknown, rowId: string) => void; }) { const tableContainerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(600); @@ -126,8 +128,9 @@ export function DatasetTable({ columnWidths, isSelected: selection.has, toggleRow, + onCellExpand, }), - [tableRows, dataset.columns, columnWidths, selection.has, toggleRow], + [tableRows, dataset.columns, columnWidths, selection.has, toggleRow, onCellExpand], ); return ( From c71d4d242f012441f3bffadf8aacf2bb33916e20 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 23 May 2026 23:49:16 +0530 Subject: [PATCH 06/10] Implemented Filter by Exact and Sub string --- frontend/app/dataset/[id]/page.tsx | 74 +++++- frontend/components/dataset/FilterPopover.tsx | 231 ++++++++++++++++++ frontend/convex/datasetRows.ts | 57 +++++ 3 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 frontend/components/dataset/FilterPopover.tsx diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index f3b06f9..48d9357 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -13,6 +13,7 @@ import { useSelection } from "@/components/table/use-selection"; import { ThemeToggle } from "@/components/ThemeToggle"; import { StatusBadge } from "@/components/dataset/StatusBadge"; import { SideSheet, CellDetail } from "@/components/SideSheet"; +import { FilterPopover, ActiveFilter } from "@/components/dataset/FilterPopover"; import { downloadCSV, downloadXLSX } from "@/lib/export"; import { populate } from "@/lib/backend"; import { EVENTS, captureException, track } from "@/lib/analytics"; @@ -35,15 +36,42 @@ export default function DatasetPage() { const updateDetails = useMutation(api.datasets.updateDetails); + /** + * Single active column filter, or null when no filter is applied. + * Supports "contains" (case-insensitive substring) and "exact" match modes. + */ + const [filter, setFilter] = useState<{ + column: string; + value: string; + matchType: "contains" | "exact"; + } | null>(null); + const datasetId = params.id as Id<"datasets">; const dataset = useQuery( api.datasets.get, authLoading ? "skip" : { id: datasetId }, ); - const rows = useQuery( + + /** + * Two query variants: + * - `listByDataset` — all rows, used when `filter === null` + * - `listByDatasetFiltered` — server-side filtered rows, used when filter is set + * + * Convex reactively swaps between them as `filter` changes, so the + * component always reads from exactly one stable source of truth. + */ + const allRows = useQuery( api.datasetRows.listByDataset, - authLoading ? "skip" : { datasetId }, + authLoading || filter !== null ? "skip" : { datasetId }, ); + const filteredRows = useQuery( + api.datasetRows.listByDatasetFiltered, + authLoading || filter === null + ? "skip" + : { datasetId, filter }, + ); + + const rows = filter === null ? allRows : filteredRows; const rowIds = useMemo(() => (rows ?? []).map((r) => r._id), [rows]); const selection = useSelection(rowIds); @@ -172,6 +200,20 @@ export default function DatasetPage() { setSideSheet({ column, value, rowId }); } + /** Set a single filter — called by FilterPopover when user clicks Apply. */ + function handleAddFilter( + column: string, + value: string, + matchType: "contains" | "exact", + ) { + setFilter({ column, value, matchType }); + } + + /** Clear the active filter — called by ActiveFilter pill ×. */ + function handleClearFilter() { + setFilter(null); + } + if (authLoading || dataset === undefined || rows === undefined) { return (
@@ -281,10 +323,36 @@ export default function DatasetPage() {
-
+

{dataset.description}

+ + + + {filter && ( + + )} + + {filter && ( + + )} +
{selectedCount > 0 && ( <> diff --git a/frontend/components/dataset/FilterPopover.tsx b/frontend/components/dataset/FilterPopover.tsx new file mode 100644 index 0000000..41ec941 --- /dev/null +++ b/frontend/components/dataset/FilterPopover.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Filter, Search, X } from "lucide-react"; +import type { DatasetColumn, DatasetRow } from "@/components/table/types"; + +/** How the filter value should be matched against cell values. */ +export type MatchType = "contains" | "exact"; + +interface FilterPopoverProps { + columns: DatasetColumn[]; + rows: DatasetRow[]; + /** + * Called when the user selects a column + value + matchType and clicks Apply. + */ + onFilter: (column: string, value: string, matchType: MatchType) => void; +} + +export function FilterPopover({ columns, rows, onFilter }: FilterPopoverProps) { + const [open, setOpen] = useState(false); + const [selectedColumn, setSelectedColumn] = useState(""); + const [selectedValue, setSelectedValue] = useState(""); + const [matchType, setMatchType] = useState("contains"); + const popoverRef = useRef(null); + const triggerRef = useRef(null); + + const selectedColDef = columns.find((c) => c.name === selectedColumn); + + /** + * Close the popover when clicking outside of it. + * The `triggerRef` is excluded so clicking the trigger button + * does not immediately close the popover. + */ + useEffect(() => { + if (!open) return; + + function handleClick(e: MouseEvent) { + const target = e.target as Node; + if ( + popoverRef.current && + !popoverRef.current.contains(target) && + !triggerRef.current?.contains(target) + ) { + setOpen(false); + } + } + + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + /** + * Reset transient selection state whenever the popover closes so the next + * open starts with a clean slate. + */ + useEffect(() => { + if (!open) { + setSelectedColumn(""); + setSelectedValue(""); + setMatchType("contains"); + } + }, [open]); + + /** + * Unique, sorted cell values for the selected column derived from all rows. + * Used only for "exact" match mode where we display a pickable list. + */ + const colValues = selectedColDef + ? Array.from( + new Set( + rows + .map((r) => r.data[selectedColDef.name]) + .map((v) => String(v ?? "")) + .filter(Boolean), + ), + ).sort() + : []; + + function handleApply() { + if (!selectedColumn || !selectedValue) return; + onFilter(selectedColumn, selectedValue, matchType); + setOpen(false); + } + + return ( +
+ {/* Trigger button */} + + + {/* Popover panel */} + {open && ( +
+ {/* Column + match-type selectors */} +
+ {/* Row: column select + match type select side-by-side */} +
+ + + +
+ + {/* Value input / search area */} + {selectedColumn && ( + <> + {matchType === "contains" ? ( +
+ + setSelectedValue(e.target.value)} + placeholder="Type to filter…" + className="w-full rounded border border-border bg-background pl-7 pr-2 py-1.5 text-xs text-foreground outline-none focus:border-foreground/30" + /> +
+ ) : ( + colValues.length > 0 ? ( +
+ {colValues.map((val) => ( + + ))} +
+ ) : ( +

+ No values found +

+ ) + )} + + )} +
+ + {/* Action bar */} +
+ + +
+
+ )} +
+ ); +} + +interface ActiveFilterProps { + column: string; + value: string; + matchType: MatchType; + onClear: () => void; +} + +export function ActiveFilter({ + column, + value, + matchType, + onClear, +}: ActiveFilterProps) { + const label = + matchType === "exact" ? `${column}: "${value}"` : `${column}: *${value}*`; + + return ( +
+ + {label} + + +
+ ); +} \ No newline at end of file diff --git a/frontend/convex/datasetRows.ts b/frontend/convex/datasetRows.ts index 567f85e..ab5558a 100644 --- a/frontend/convex/datasetRows.ts +++ b/frontend/convex/datasetRows.ts @@ -25,6 +25,63 @@ export const listByDataset = query({ }, }); +/** + * Filtered row query — applies a single column=value filter server-side + * before returning rows. + * + * Supports two match modes: + * - "contains" : case-insensitive substring match (default) + * - "exact" : case-insensitive full-string equality + * + * Passing `null` for the filter argument returns all rows identically to + * `listByDataset`, which lets the frontend reuse one stable query hook. + * + * PERFORMANCE NOTE: `datasetRows.data` is a `v.record(v.string(), v.any())` + * and is not indexed per-field. The filter loop performs a full scan over + * every row in the dataset. This is acceptable for datasets up to ~100 k + * rows but will become a bottleneck at scale. A future optimisation would + * layer in a Convex vector index or a dedicated search table. + */ +export const listByDatasetFiltered = query({ + args: { + datasetId: v.id("datasets"), + /** + * Single filter predicate, or `null` to return all rows. + * A `null` filter is treated identically to calling `listByDataset`. + */ + filter: v.nullable( + v.object({ + column: v.string(), + value: v.string(), + matchType: v.optional( + v.union(v.literal("contains"), v.literal("exact")), + ), + }), + ), + }, + handler: async (ctx, args) => { + await loadReadableDataset(ctx, args.datasetId); + + const rows = await ctx.db + .query("datasetRows") + .withIndex("by_dataset", (q) => q.eq("datasetId", args.datasetId)) + .collect(); + + if (!args.filter || !args.filter.value) return rows; + + const { column, value, matchType = "contains" } = args.filter; + const cellStr = (v: unknown) => String(v ?? "").toLowerCase(); + const searchVal = value.toLowerCase(); + + return rows.filter((row) => { + const cell = row.data[column]; + if (cell == null) return false; + const s = cellStr(cell); + return matchType === "exact" ? s === searchVal : s.includes(searchVal); + }); + }, +}); + /** * Row writes are SYSTEM-LEVEL operations performed by the agent runner, * never by end users directly. They are exposed as `internalMutation` so From 502eb26da324e64733175b190a72f46db5f3fee0 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sun, 24 May 2026 10:34:20 +0530 Subject: [PATCH 07/10] Enhance DatasetPage with ownership checks for editing and improve SideSheet overflow handling --- frontend/app/dataset/[id]/page.tsx | 23 ++++++++++++++--------- frontend/app/globals.css | 6 ++++++ frontend/components/SideSheet.tsx | 11 ++++++++--- frontend/components/table/DataRow.tsx | 4 ++-- frontend/convex/datasets.ts | 14 +++++++++----- 5 files changed, 39 insertions(+), 19 deletions(-) diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index 48d9357..875b0e7 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -53,16 +53,14 @@ export default function DatasetPage() { ); /** - * Two query variants: - * - `listByDataset` — all rows, used when `filter === null` - * - `listByDatasetFiltered` — server-side filtered rows, used when filter is set - * - * Convex reactively swaps between them as `filter` changes, so the - * component always reads from exactly one stable source of truth. + * allRows is always the full unfiltered dataset — needed by FilterPopover's + * exact-match value picklist regardless of whether a filter is active. + * filteredRows is only active when filter is set. + * Convex deduplicates identical subscriptions so no extra network cost. */ const allRows = useQuery( api.datasetRows.listByDataset, - authLoading || filter !== null ? "skip" : { datasetId }, + authLoading ? "skip" : { datasetId }, ); const filteredRows = useQuery( api.datasetRows.listByDatasetFiltered, @@ -73,6 +71,9 @@ export default function DatasetPage() { const rows = filter === null ? allRows : filteredRows; + /** True when the signed-in user owns this dataset — gates write actions. */ + const isOwner = userId != null && dataset?.ownerId != null && userId === dataset.ownerId; + const rowIds = useMemo(() => (rows ?? []).map((r) => r._id), [rows]); const selection = useSelection(rowIds); const selectedCount = selection.selected.size; @@ -162,7 +163,7 @@ export default function DatasetPage() { } function startEditingName() { - if (!dataset) return; + if (!dataset || !isOwner) return; setNameValue(dataset.name); setEditingName(true); setTimeout(() => nameInputRef.current?.select(), 0); @@ -262,7 +263,7 @@ export default function DatasetPage() { }} className="text-sm font-semibold tracking-tight truncate max-w-md rounded border border-border bg-background px-2 py-0.5 outline-none focus:border-foreground/30" /> - ) : ( + ) : isOwner ? ( + ) : ( +

+ {dataset.name} +

)}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index bf8853b..b446ec6 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -99,3 +99,9 @@ body { .animate-slide-in { animation: slide-in 0.3s ease-out; } + +@media (prefers-reduced-motion: reduce) { + .animate-slide-in { + animation: none; + } +} diff --git a/frontend/components/SideSheet.tsx b/frontend/components/SideSheet.tsx index e3b3fc4..a3a3b8e 100644 --- a/frontend/components/SideSheet.tsx +++ b/frontend/components/SideSheet.tsx @@ -12,15 +12,19 @@ interface SideSheetProps { export function SideSheet({ open, onClose, children }: SideSheetProps) { const panelRef = useRef(null); + const prevOverflowRef = useRef(null); useEffect(() => { if (open) { + prevOverflowRef.current = document.body.style.overflow; document.body.style.overflow = "hidden"; } else { - document.body.style.overflow = ""; + document.body.style.overflow = prevOverflowRef.current ?? ""; + prevOverflowRef.current = null; } return () => { - document.body.style.overflow = ""; + document.body.style.overflow = prevOverflowRef.current ?? ""; + prevOverflowRef.current = null; }; }, [open]); @@ -70,7 +74,8 @@ export function CellDetail({ async function handleCopy() { try { - await navigator.clipboard.writeText(displayValue); + // Write the raw underlying value, not the display fallback "—". + await navigator.clipboard.writeText(value == null ? "" : String(value)); toast.success("Copied to clipboard"); } catch { toast.error("Failed to copy"); diff --git a/frontend/components/table/DataRow.tsx b/frontend/components/table/DataRow.tsx index 4864f1b..54d138b 100644 --- a/frontend/components/table/DataRow.tsx +++ b/frontend/components/table/DataRow.tsx @@ -83,8 +83,8 @@ function DataRowImpl({ e.stopPropagation(); onCellExpand(col.name, value, row.original._id); }} - className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 p-0.5 rounded bg-foreground/5 hover:bg-foreground/10 text-muted hover:text-foreground transition-all" - aria-label="Expand cell" + className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 focus:opacity-100 p-0.5 rounded bg-foreground/5 hover:bg-foreground/10 text-muted hover:text-foreground transition-all" + aria-label={`Expand ${col.name} cell`} > diff --git a/frontend/convex/datasets.ts b/frontend/convex/datasets.ts index e3dc586..ac43112 100644 --- a/frontend/convex/datasets.ts +++ b/frontend/convex/datasets.ts @@ -141,11 +141,15 @@ export const updateDetails = mutation({ const trimmedName = args.name.trim(); if (!trimmedName) throw new Error("Dataset name cannot be empty"); const dataset = await loadOwnedDataset(ctx, args.id); - if (trimmedName === dataset.name && args.description === dataset.description) return; - await ctx.db.patch(dataset._id, { - name: trimmedName, - description: args.description, - }); + + // Skip entirely if neither field actually changed. + const nameChanged = trimmedName !== dataset.name; + const descChanged = args.description !== dataset.description; + if (!nameChanged && !descChanged) return; + + const patch: Record = { name: trimmedName }; + if (descChanged) patch.description = args.description; + await ctx.db.patch(dataset._id, patch); }, }); From f83f5af0677f37ed057a1830fb8058848f574fee Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Thu, 28 May 2026 17:07:15 +0530 Subject: [PATCH 08/10] Refactor DatasetPage and SideSheet for improved accessibility and performance; update dataset mutation to handle optional description --- frontend/app/dataset/[id]/page.tsx | 22 +++----- frontend/components/SideSheet.tsx | 51 ++++++++++++++++--- frontend/components/dataset/FilterPopover.tsx | 24 +++++---- frontend/convex/datasetRows.ts | 4 ++ frontend/convex/datasets.ts | 7 ++- 5 files changed, 71 insertions(+), 37 deletions(-) diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index 428be88..a402cc7 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -2,7 +2,7 @@ import { useParams } from "next/navigation"; import Link from "next/link"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useMutation, useConvexAuth } from "convex/react"; import { useAuth } from "@clerk/nextjs"; import { api } from "@/convex/_generated/api"; @@ -207,7 +207,6 @@ export default function DatasetPage() { await updateDetails({ id: dataset._id, name: trimmed, - description: dataset.description, }); toast.success("Dataset name updated"); } catch { @@ -221,12 +220,12 @@ export default function DatasetPage() { setEditingName(false); } - function handleCellExpand(columnName: string, value: unknown, rowId: string) { + const handleCellExpand = useCallback((columnName: string, value: unknown, rowId: string) => { if (!dataset) return; const column = dataset.columns.find((c) => c.name === columnName); if (!column) return; setSideSheet({ column, value, rowId }); - } + }, [dataset]); /** Set a single filter — called by FilterPopover when user clicks Apply. */ function handleAddFilter( @@ -300,7 +299,10 @@ export default function DatasetPage() { onChange={(e) => setNameValue(e.target.value)} onBlur={saveName} onKeyDown={(e) => { - if (e.key === "Enter") saveName(); + if (e.key === "Enter") { + e.preventDefault(); + nameInputRef.current?.blur(); + } if (e.key === "Escape") cancelNameEdit(); }} className="text-sm font-semibold tracking-tight truncate max-w-md rounded border border-border bg-background px-2 py-0.5 outline-none focus:border-foreground/30" @@ -396,16 +398,6 @@ export default function DatasetPage() { onClear={handleClearFilter} /> )} - - {filter && ( - - )}
{ document.body.style.overflow = prevOverflowRef.current ?? ""; @@ -28,10 +25,51 @@ export function SideSheet({ open, onClose, children }: SideSheetProps) { }; }, [open]); + useEffect(() => { + if (!open || !panelRef.current) return; + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + + const panel = panelRef.current; + const focusable = panel.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + const firstFocusable = focusable[0]; + const lastFocusable = focusable[focusable.length - 1]; + + function handleTab(e: KeyboardEvent) { + if (e.key !== "Tab") return; + if (focusable.length === 0) return; + + if (e.shiftKey) { + if (document.activeElement === firstFocusable) { + e.preventDefault(); + lastFocusable.focus(); + } + } else { + if (document.activeElement === lastFocusable) { + e.preventDefault(); + firstFocusable.focus(); + } + } + } + + panel.addEventListener("keydown", handleKeyDown); + panel.addEventListener("keydown", handleTab); + firstFocusable?.focus(); + + return () => { + panel.removeEventListener("keydown", handleKeyDown); + panel.removeEventListener("keydown", handleTab); + }; + }, [open, onClose]); + if (!open) return null; return ( -
+
-

{columnName}

{description && (

{description}

@@ -109,12 +145,13 @@ export function CellDetail({ onClick={handleCopy} className="inline-flex items-center gap-1.5 text-[11px] font-medium text-muted hover:text-foreground transition-colors" aria-label="Copy value" + data-ph-mask-text="true" > Copy
-
+

{displayValue}

diff --git a/frontend/components/dataset/FilterPopover.tsx b/frontend/components/dataset/FilterPopover.tsx index 41ec941..b0f93f2 100644 --- a/frontend/components/dataset/FilterPopover.tsx +++ b/frontend/components/dataset/FilterPopover.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Filter, Search, X } from "lucide-react"; import type { DatasetColumn, DatasetRow } from "@/components/table/types"; @@ -64,17 +64,19 @@ export function FilterPopover({ columns, rows, onFilter }: FilterPopoverProps) { /** * Unique, sorted cell values for the selected column derived from all rows. * Used only for "exact" match mode where we display a pickable list. + * Memoized to avoid recomputing on every render (e.g., keystroke in search box). */ - const colValues = selectedColDef - ? Array.from( - new Set( - rows - .map((r) => r.data[selectedColDef.name]) - .map((v) => String(v ?? "")) - .filter(Boolean), - ), - ).sort() - : []; + const colValues = useMemo(() => { + if (!selectedColDef) return []; + return Array.from( + new Set( + rows + .map((r) => r.data[selectedColDef.name]) + .map((v) => String(v ?? "")) + .filter(Boolean), + ), + ).sort(); + }, [rows, selectedColDef]); function handleApply() { if (!selectedColumn || !selectedValue) return; diff --git a/frontend/convex/datasetRows.ts b/frontend/convex/datasetRows.ts index d998712..d8dbb57 100644 --- a/frontend/convex/datasetRows.ts +++ b/frontend/convex/datasetRows.ts @@ -236,6 +236,10 @@ export const get = internalQuery({ export const countByDataset = internalQuery({ args: { datasetId: v.id("datasets") }, handler: async (ctx, args) => { + const dataset = await ctx.db.get(args.datasetId); + if (dataset && typeof dataset.rowCount === "number") { + return dataset.rowCount; + } const rows = await ctx.db .query("datasetRows") .withIndex("by_dataset", (q) => q.eq("datasetId", args.datasetId)) diff --git a/frontend/convex/datasets.ts b/frontend/convex/datasets.ts index ac43112..c3891fe 100644 --- a/frontend/convex/datasets.ts +++ b/frontend/convex/datasets.ts @@ -135,19 +135,18 @@ export const updateDetails = mutation({ args: { id: v.id("datasets"), name: v.string(), - description: v.string(), + description: v.optional(v.string()), }, handler: async (ctx, args) => { const trimmedName = args.name.trim(); if (!trimmedName) throw new Error("Dataset name cannot be empty"); const dataset = await loadOwnedDataset(ctx, args.id); - // Skip entirely if neither field actually changed. const nameChanged = trimmedName !== dataset.name; - const descChanged = args.description !== dataset.description; + const descChanged = args.description !== undefined && args.description !== dataset.description; if (!nameChanged && !descChanged) return; - const patch: Record = { name: trimmedName }; + const patch: Partial> = { name: trimmedName }; if (descChanged) patch.description = args.description; await ctx.db.patch(dataset._id, patch); }, From 191d86ab39451c6fbbf6f4c71acc2d3e52086559 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Thu, 28 May 2026 17:23:32 +0530 Subject: [PATCH 09/10] Refactor DatasetPage component: streamline imports, enhance export and settings dropdowns, and improve filter handling --- frontend/app/dataset/[id]/page.tsx | 675 +++++++++++++++++++---------- 1 file changed, 440 insertions(+), 235 deletions(-) diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index a402cc7..cc29d31 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -3,62 +3,46 @@ import { useParams } from "next/navigation"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useQuery, useMutation, useConvexAuth } from "convex/react"; -import { useAuth } from "@clerk/nextjs"; +import { useQuery, useConvexAuth } from "convex/react"; +import { useAuth, useUser, useClerk } from "@clerk/nextjs"; +import type { UserResource } from "@clerk/types"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { DatasetTable } from "@/components/table"; import type { DatasetColumn } from "@/components/table/types"; import { useSelection } from "@/components/table/use-selection"; -import { ThemeToggle } from "@/components/ThemeToggle"; +import { useTheme } from "@/components/ThemeToggle"; import { StatusBadge } from "@/components/dataset/StatusBadge"; -import { SideSheet, CellDetail } from "@/components/SideSheet"; import { FilterPopover, ActiveFilter } from "@/components/dataset/FilterPopover"; import { downloadCSV, downloadXLSX } from "@/lib/export"; import { populate, update } from "@/lib/backend"; import { EVENTS, captureException, track } from "@/lib/analytics"; -import { toast } from "@/components/Toaster"; +import type { MatchType } from "@/components/dataset/FilterPopover"; export default function DatasetPage() { const params = useParams(); const { isLoading: authLoading } = useConvexAuth(); const { userId, getToken } = useAuth(); + const { user } = useUser(); + const { signOut } = useClerk(); const [exporting, setExporting] = useState<"csv" | "xlsx" | null>(null); const [populating, setPopulating] = useState(false); - const [editingName, setEditingName] = useState(false); - const [nameValue, setNameValue] = useState(""); - const nameInputRef = useRef(null); - const [sideSheet, setSideSheet] = useState<{ - column: DatasetColumn; - value: unknown; - rowId: string; - } | null>(null); - - const updateDetails = useMutation(api.datasets.updateDetails); + const [updating, setUpdating] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [confirmPopulate, setConfirmPopulate] = useState(false); - /** - * Single active column filter, or null when no filter is applied. - * Supports "contains" (case-insensitive substring) and "exact" match modes. - */ const [filter, setFilter] = useState<{ column: string; value: string; - matchType: "contains" | "exact"; + matchType: MatchType; } | null>(null); - const [updating, setUpdating] = useState(false); const datasetId = params.id as Id<"datasets">; const dataset = useQuery( api.datasets.get, authLoading ? "skip" : { id: datasetId }, ); - - /** - * allRows is always the full unfiltered dataset — needed by FilterPopover's - * exact-match value picklist regardless of whether a filter is active. - * filteredRows is only active when filter is set. - * Convex deduplicates identical subscriptions so no extra network cost. - */ const allRows = useQuery( api.datasetRows.listByDataset, authLoading ? "skip" : { datasetId }, @@ -72,16 +56,42 @@ export default function DatasetPage() { const rows = filter === null ? allRows : filteredRows; - /** True when the signed-in user owns this dataset — gates write actions. */ - const isOwner = userId != null && dataset?.ownerId != null && userId === dataset.ownerId; - const rowIds = useMemo(() => (rows ?? []).map((r) => r._id), [rows]); const selection = useSelection(rowIds); const selectedCount = selection.selected.size; - // Fire dataset_opened once per dataset visit, after the dataset has - // resolved. The ref keeps it idempotent across re-renders. + const handlePopulate = useCallback(async () => { + if (!dataset || populating || dataset.status === "building") return; + setPopulating(true); + try { + const token = await getToken(); + if (!token) throw new Error("Not authenticated"); + + const startedRun = await populate( + dataset._id, + dataset.name, + dataset.description, + dataset.columns, + token, + ); + track(EVENTS.DATASET_POPULATE_STARTED, { + datasetId: dataset._id, + column_count: dataset.columns.length, + runId: startedRun.runId, + }); + } catch (err) { + console.error("[populate] failed", err); + captureException(err, { + operation: "dataset_populate", + datasetId: dataset._id, + }); + } finally { + setPopulating(false); + } + }, [dataset, populating, getToken]); + const openedFired = useRef(null); + const autoPopulateFired = useRef(null); useEffect(() => { if (dataset && openedFired.current !== dataset._id) { openedFired.current = dataset._id; @@ -92,14 +102,21 @@ export default function DatasetPage() { is_owner: userId === dataset.ownerId, }); } - }, [dataset, userId]); + if ( + dataset && + autoPopulateFired.current !== dataset._id && + dataset.status === "paused" && + (dataset.rowCount ?? 0) === 0 && + userId === dataset.ownerId + ) { + autoPopulateFired.current = dataset._id; + handlePopulate(); + } + }, [dataset, userId, handlePopulate]); async function handleExport(format: "csv" | "xlsx") { if (!dataset || !rows || exporting) return; - // If the user has rows selected, export ONLY those. Otherwise the - // entire dataset. Preserves column ordering (handled by the export - // util — it iterates `dataset.columns` in order). const exportRows = selectedCount > 0 ? rows.filter((r) => selection.selected.has(r._id)) @@ -134,6 +151,22 @@ export default function DatasetPage() { } } + function handleAddFilter( + column: string, + value: string, + matchType: MatchType, + ) { + setFilter({ column, value, matchType }); + } + + function handleClearFilter() { + setFilter(null); + } + + const handleCellExpand = useCallback((columnName: string, value: unknown, rowId: string) => { + // Cell expansion not yet implemented in this UI + }, []); + async function handleUpdate() { if (!dataset || updating || dataset.status === "building") return; setUpdating(true); @@ -159,88 +192,6 @@ export default function DatasetPage() { } } - async function handlePopulate() { - if (!dataset || populating || dataset.status === "building") return; - setPopulating(true); - try { - const token = await getToken(); - if (!token) throw new Error("Not authenticated"); - - const startedRun = await populate( - dataset._id, - dataset.name, - dataset.description, - dataset.columns, - token, - ); - track(EVENTS.DATASET_POPULATE_STARTED, { - datasetId: dataset._id, - column_count: dataset.columns.length, - runId: startedRun.runId, - }); - } catch (err) { - console.error("[populate] failed", err); - captureException(err, { - operation: "dataset_populate", - datasetId: dataset._id, - }); - } finally { - setPopulating(false); - } - } - - function startEditingName() { - if (!dataset || !isOwner) return; - setNameValue(dataset.name); - setEditingName(true); - setTimeout(() => nameInputRef.current?.select(), 0); - } - - async function saveName() { - if (!dataset) return; - const trimmed = nameValue.trim(); - if (!trimmed || trimmed === dataset.name) { - setEditingName(false); - return; - } - try { - await updateDetails({ - id: dataset._id, - name: trimmed, - }); - toast.success("Dataset name updated"); - } catch { - toast.error("Failed to update dataset name"); - } finally { - setEditingName(false); - } - } - - function cancelNameEdit() { - setEditingName(false); - } - - const handleCellExpand = useCallback((columnName: string, value: unknown, rowId: string) => { - if (!dataset) return; - const column = dataset.columns.find((c) => c.name === columnName); - if (!column) return; - setSideSheet({ column, value, rowId }); - }, [dataset]); - - /** Set a single filter — called by FilterPopover when user clicks Apply. */ - function handleAddFilter( - column: string, - value: string, - matchType: "contains" | "exact", - ) { - setFilter({ column, value, matchType }); - } - - /** Clear the active filter — called by ActiveFilter pill ×. */ - function handleClearFilter() { - setFilter(null); - } - if (authLoading || dataset === undefined || rows === undefined) { return (
@@ -269,121 +220,67 @@ export default function DatasetPage() { : dataset.status === "failed" ? "Retry Populate" : "Clear & Populate"; - const csvLabel = - exporting === "csv" - ? "Exporting…" - : selectedCount > 0 - ? `Export CSV (${selectedCount})` - : "Export CSV"; - const xlsxLabel = - exporting === "xlsx" - ? "Exporting…" - : selectedCount > 0 - ? `Export XLSX (${selectedCount})` - : "Export XLSX"; + const exportLabel = exporting + ? "Exporting…" + : selectedCount > 0 + ? `Export (${selectedCount})` + : "Export"; return (
-
-
- - BigSet - BigSet +
+
+ + BigSet + BigSet - / - {editingName ? ( - setNameValue(e.target.value)} - onBlur={saveName} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - nameInputRef.current?.blur(); - } - if (e.key === "Escape") cancelNameEdit(); - }} - className="text-sm font-semibold tracking-tight truncate max-w-md rounded border border-border bg-background px-2 py-0.5 outline-none focus:border-foreground/30" - /> - ) : isOwner ? ( - - ) : ( -

- {dataset.name} -

- )} + +

+ {dataset.name} +

-
- - {dataset.cadence} - - - - - -
- + exporting={exporting} + selectedCount={selectedCount} + onExport={(fmt) => { setExportOpen(false); handleExport(fmt); }} + /> + + setSettingsOpen((o) => !o)} + onClose={() => setSettingsOpen(false)} + cadence={dataset.cadence} + updateLabel={updateLabel} + updateDisabled={updateDisabled} + populateLabel={populateLabel} + populateDisabled={populateDisabled} + onUpdate={() => { setSettingsOpen(false); handleUpdate(); }} + onPopulate={() => { + setSettingsOpen(false); + if (rows.length > 0) { + setConfirmPopulate(true); + } else { + handlePopulate(); + } + }} + /> + +
+ + signOut()} />
-
-

- {dataset.description} -

- +
)} + +
+ +
+

+ {dataset.description} +

+ {dataset.status === "failed" && dataset.lastStatusError && ( +

+ Last populate failed: {dataset.lastStatusError} +

+ )} +
+ +
+ {selectedCount > 0 && ( + <> + + {selectedCount} selected + + | + + )} + {rows.length} rows + | + {dataset.columns.length} columns +
- setSideSheet(null)} + {confirmPopulate && ( + { + setConfirmPopulate(false); + handlePopulate(); + }} + onCancel={() => setConfirmPopulate(false)} + /> + )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Export dropdown */ +/* ------------------------------------------------------------------ */ + +function ExportDropdown({ + open, + onToggle, + onClose, + label, + disabled, + exporting, + selectedCount, + onExport, +}: { + open: boolean; + onToggle: () => void; + onClose: () => void; + label: string; + disabled: boolean; + exporting: "csv" | "xlsx" | null; + selectedCount: number; + onExport: (fmt: "csv" | "xlsx") => void; +}) { + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open, onClose]); + + const hint = selectedCount > 0 + ? `${selectedCount} row${selectedCount === 1 ? "" : "s"} selected` + : "All rows"; + + return ( +
+ + + {open && ( +
+
+

{hint}

+
+
+ + +
+
+ )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Settings dropdown */ +/* ------------------------------------------------------------------ */ + +function SettingsDropdown({ + open, + onToggle, + onClose, + cadence, + updateLabel, + updateDisabled, + populateLabel, + populateDisabled, + onUpdate, + onPopulate, +}: { + open: boolean; + onToggle: () => void; + onClose: () => void; + cadence: string; + updateLabel: string; + updateDisabled: boolean; + populateLabel: string; + populateDisabled: boolean; + onUpdate: () => void; + onPopulate: () => void; +}) { + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open, onClose]); + + return ( +
+ + + {open && ( +
+
+ + +
+
+ {cadence} +
+
+ )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Profile menu (matches dashboard) */ +/* ------------------------------------------------------------------ */ + +function DatasetProfileMenu({ + user, + onSignOut, +}: { + user: UserResource | null | undefined; + onSignOut: () => void; +}) { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + const { theme, toggle: toggleTheme } = useTheme(); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) + setOpen(false); + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + const name = user?.fullName || user?.firstName || "User"; + const email = user?.primaryEmailAddress?.emailAddress; + const imageUrl = user?.imageUrl; + + return ( +
+ + + {open && ( +
+
+

{name}

+ {email && ( +

{email}

+ )} +
+
+ + +
+
+ )}
); } + +/* ------------------------------------------------------------------ */ +/* Confirm populate modal */ +/* ------------------------------------------------------------------ */ + +function ConfirmPopulateModal({ + rowCount, + onConfirm, + onCancel, +}: { + rowCount: number; + onConfirm: () => void; + onCancel: () => void; +}) { + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") onCancel(); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [onCancel]); + + return ( +
{ if (e.target === e.currentTarget) onCancel(); }} + role="presentation" + > +
+

+ This will delete {rowCount === 1 ? "1 row" : `${rowCount} rows`}. This can't be undone. +

+
+ + +
+
+
+ ); +} \ No newline at end of file From bc058741d77b59b3990326f93c61cb548fd8553d Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 30 May 2026 23:01:36 +0530 Subject: [PATCH 10/10] Add lucide-react and sonner dependencies to bun.lock --- frontend/bun.lock | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/bun.lock b/frontend/bun.lock index 131d93c..bcff005 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -8,11 +8,13 @@ "@clerk/nextjs": "^7.3.7", "@tanstack/react-table": "^8.21.3", "convex": "^1.39.1", + "lucide-react": "^1.16.0", "next": "16.2.6", "posthog-js": "^1.374.2", "react": "19.2.4", "react-dom": "19.2.4", "react-window": "^1.8.11", + "sonner": "^2.0.7", "xlsx": "^0.18.5", }, "devDependencies": { @@ -795,6 +797,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -935,6 +939,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],