From 19757831470279e7d43fea00938c0cd7b309fe39 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Thu, 4 Jun 2026 22:58:19 +0530 Subject: [PATCH 1/6] feat: add filter popover, dataset name editing, and toast notifications --- frontend/app/dataset/[id]/page.tsx | 146 +++++++++- frontend/app/layout.tsx | 2 + frontend/bun.lock | 3 + frontend/components/Toaster.tsx | 79 ++++++ frontend/components/ToasterProvider.tsx | 7 + frontend/components/dataset/FilterPopover.tsx | 251 ++++++++++++++++++ frontend/convex/datasetRows.ts | 57 ++++ frontend/convex/datasets.ts | 21 ++ frontend/convex/scripts.ts | 170 ++++++++++++ frontend/package.json | 1 + 10 files changed, 725 insertions(+), 12 deletions(-) create mode 100644 frontend/components/Toaster.tsx create mode 100644 frontend/components/ToasterProvider.tsx create mode 100644 frontend/components/dataset/FilterPopover.tsx create mode 100644 frontend/convex/scripts.ts diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index d9ee1b6..d7a6b7a 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -13,6 +13,7 @@ import { SideSheet, CellDetail } from "@/components/SideSheet"; import type { DatasetColumn } from "@/components/table/types"; import { useTheme } from "@/components/ThemeToggle"; import { StatusBadge } from "@/components/dataset/StatusBadge"; +import { FilterPopover, ActiveFilter } from "@/components/dataset/FilterPopover"; import { downloadCSV, downloadXLSX } from "@/lib/export"; import { populate, update, stopPopulation } from "@/lib/backend"; import { EVENTS, captureException, track } from "@/lib/analytics"; @@ -22,6 +23,7 @@ import { type RefreshCadence, } from "@/lib/refresh-cadence"; import type { ProfileUser } from "@/lib/profile-user"; +import { toast } from "@/components/Toaster"; export default function DatasetPage() { const params = useParams(); @@ -37,6 +39,19 @@ export default function DatasetPage() { const [settingsOpen, setSettingsOpen] = useState(false); const [confirmPopulate, setConfirmPopulate] = useState(false); const [savingRefreshCadence, setSavingRefreshCadence] = useState(false); + + const updateDetails = useMutation(api.datasets.updateDetails); + + const [filter, setFilter] = useState<{ + column: string; + value: string; + matchType: "contains" | "exact"; + } | null>(null); + + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(""); + const [savingName, setSavingName] = useState(false); + const nameInputRef = useRef(null); const [cellDetail, setCellDetail] = useState<{ column: DatasetColumn; value: unknown; @@ -52,9 +67,17 @@ export default function DatasetPage() { api.datasetRows.listByDataset, authLoading ? "skip" : { datasetId }, ); + const filteredRows = useQuery( + api.datasetRows.listByDatasetFiltered, + authLoading || filter === null + ? "skip" + : { datasetId, filter }, + ); + + const displayRows = filter === null ? (rows ?? []) : (filteredRows ?? []); const updateRefreshSettings = useMutation(api.datasets.updateRefreshSettings); - const rowIds = useMemo(() => (rows ?? []).map((r) => r._id), [rows]); + const rowIds = useMemo(() => (displayRows ?? []).map((r) => r._id), [displayRows]); const selection = useSelection(rowIds); const selectedCount = selection.selected.size; @@ -123,12 +146,12 @@ export default function DatasetPage() { }, [dataset, userId, handlePopulate]); async function handleExport(format: "csv" | "xlsx") { - if (!dataset || !rows || exporting) return; + if (!dataset || !displayRows || exporting) return; const exportRows = selectedCount > 0 - ? rows.filter((r) => selection.selected.has(r._id)) - : rows; + ? displayRows.filter((r) => selection.selected.has(r._id)) + : displayRows; if (exportRows.length === 0) return; setExporting(format); @@ -141,7 +164,7 @@ export default function DatasetPage() { track(EVENTS.DATASET_EXPORTED, { format, row_count: exportRows.length, - total_rows: rows.length, + total_rows: displayRows.length, selected_only: selectedCount > 0, seedKey: dataset.seedKey, }); @@ -196,6 +219,51 @@ export default function DatasetPage() { } } + function handleAddFilter( + column: string, + value: string, + matchType: "contains" | "exact", + ) { + setFilter({ column, value, matchType }); + } + + function handleClearFilter() { + setFilter(null); + } + + function startEditingName() { + if (!dataset || userId !== dataset.ownerId) return; + setNameValue(dataset.name); + setEditingName(true); + setTimeout(() => nameInputRef.current?.select(), 0); + } + + async function saveName() { + if (!dataset || savingName) return; + const trimmed = nameValue.trim(); + if (!trimmed || trimmed === dataset.name) { + setEditingName(false); + return; + } + setSavingName(true); + try { + await updateDetails({ + id: dataset._id, + name: trimmed, + }); + toast.success("Dataset name updated"); + } catch { + toast.error("Failed to update dataset name"); + } finally { + setSavingName(false); + setEditingName(false); + } + } + + function cancelNameEdit() { + setEditingName(false); + } + async function handleRefreshCadenceChange(refreshCadence: RefreshCadence) { if (!dataset || savingRefreshCadence || userId !== dataset.ownerId) return; setSavingRefreshCadence(true); @@ -269,7 +337,7 @@ export default function DatasetPage() { // the "Dataset not found" UI. const exportDisabled = exporting !== null || rows.length === 0; - const isOwner = userId === dataset.ownerId; + const isOwner = userId != null && dataset?.ownerId != null && userId === dataset.ownerId; const displayDataset = { ...dataset, refreshCadence: dataset.refreshCadence ?? "daily", @@ -312,9 +380,46 @@ export default function DatasetPage() { -

- {dataset.name} -

+ {editingName ? ( + setNameValue(e.target.value)} + onBlur={saveName} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + nameInputRef.current?.blur(); + } + if (e.key === "Escape") cancelNameEdit(); + }} + disabled={savingName} + 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 disabled:opacity-50 disabled:cursor-wait" + /> + ) : isOwner ? ( + + ) : ( +

+ {dataset.name} +

+ )}
@@ -370,6 +475,23 @@ export default function DatasetPage() {
+ + + {filter && ( + + )} + +
+

{dataset.description} @@ -389,7 +511,7 @@ export default function DatasetPage() { | )} - {rows.length} rows + {displayRows.length} rows | {dataset.columns.length} columns

@@ -397,7 +519,7 @@ export default function DatasetPage() { { setConfirmPopulate(false); handlePopulate(); diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index c4955a3..3122a20 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { ClerkProvider } from "@clerk/nextjs"; import { ConvexClientProvider } from "./convex-provider"; import { AnalyticsProvider } from "@/lib/analytics-provider"; +import { ToasterProvider } from "@/components/ToasterProvider"; import "./globals.css"; const geistSans = Geist({ @@ -52,6 +53,7 @@ export default function RootLayout({ > {children} + diff --git a/frontend/bun.lock b/frontend/bun.lock index d96b019..0819d99 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -14,6 +14,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-window": "^1.8.11", + "sonner": "^2.0.7", "xlsx": "^0.18.5", }, "devDependencies": { @@ -938,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=="], diff --git a/frontend/components/Toaster.tsx b/frontend/components/Toaster.tsx new file mode 100644 index 0000000..ce2f07a --- /dev/null +++ b/frontend/components/Toaster.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { + CircleCheck, + Info, + Loader2, + OctagonX, + TriangleAlert, +} from "lucide-react"; +import { Toaster as Sonner, toast, type ToasterProps } from "sonner"; + +function getInitialTheme(): "light" | "dark" { + if (typeof window === "undefined") return "light"; + const stored = localStorage.getItem("bigset:theme"); + if (stored === "dark" || stored === "light") return stored; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function useTheme() { + const [theme, setTheme] = useState<"light" | "dark">(() => getInitialTheme()); + + const readTheme = useCallback(() => { + const stored = localStorage.getItem("bigset:theme"); + if (stored === "dark" || stored === "light") return stored as "light" | "dark"; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + }, []); + + useEffect(() => { + const html = document.documentElement; + + const observer = new MutationObserver(() => { + setTheme(readTheme()); + }); + observer.observe(html, { attributes: true, attributeFilter: ["data-theme"] }); + return () => observer.disconnect(); + }, [readTheme]); + + 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/components/ToasterProvider.tsx b/frontend/components/ToasterProvider.tsx new file mode 100644 index 0000000..c6fcb81 --- /dev/null +++ b/frontend/components/ToasterProvider.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { BigSetToaster } from "./Toaster"; + +export function ToasterProvider() { + return ; +} diff --git a/frontend/components/dataset/FilterPopover.tsx b/frontend/components/dataset/FilterPopover.tsx new file mode 100644 index 0000000..8cac94c --- /dev/null +++ b/frontend/components/dataset/FilterPopover.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useEffect, useLayoutEffect, useMemo, 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 wasOpenRef = useRef(false); + + 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. + * Uses useLayoutEffect to detect the transition from open → closed before paint. + */ + useLayoutEffect(() => { + if (open === false && wasOpenRef.current === true) { + setSelectedColumn(""); + setSelectedValue(""); + setMatchType("contains"); + } + wasOpenRef.current = open; + }, [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. + * Memoized to avoid recomputing on every render (e.g., keystroke in search box). + */ + 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; + onFilter(selectedColumn, selectedValue, matchType); + setOpen(false); + } + + return ( +
+ {/* Trigger button */} + + + {/* Popover panel */} + {open && ( +
{ + if (e.key === "Escape") { + setOpen(false); + } + }} + className="absolute top-full left-0 mt-1.5 w-72 bg-surface border border-border rounded-lg shadow-lg z-50" + > + {/* Column + match-type selectors */} +
+ {/* Row: column select + match type select side-by-side */} +
+ + + +
+ + {/* Value input / search area */} + {selectedColumn && ( + <> + {matchType === "contains" ? ( +
+ + setSelectedValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && selectedColumn && selectedValue) { + handleApply(); + } + }} + 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 0536f3b..1370fff 100644 --- a/frontend/convex/datasetRows.ts +++ b/frontend/convex/datasetRows.ts @@ -44,6 +44,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 === undefined || args.filter.value === null || 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 diff --git a/frontend/convex/datasets.ts b/frontend/convex/datasets.ts index 4f34619..470639e 100644 --- a/frontend/convex/datasets.ts +++ b/frontend/convex/datasets.ts @@ -408,6 +408,27 @@ export const updateRefreshSettings = mutation({ }, }); +export const updateDetails = mutation({ + args: { + id: v.id("datasets"), + name: 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); + + const nameChanged = trimmedName !== dataset.name; + const descChanged = args.description !== undefined && args.description !== dataset.description; + if (!nameChanged && !descChanged) return; + + const patch: Partial> = { name: trimmedName }; + if (descChanged) patch.description = args.description; + await ctx.db.patch(dataset._id, patch); + }, +}); + export const backfillRefreshSettings = internalMutation({ args: { defaultCadence: v.optional(refreshCadenceValidator), diff --git a/frontend/convex/scripts.ts b/frontend/convex/scripts.ts new file mode 100644 index 0000000..1b9bdae --- /dev/null +++ b/frontend/convex/scripts.ts @@ -0,0 +1,170 @@ +import { internalMutation, internalQuery } from "./_generated/server.js"; +import { v } from "convex/values"; +import { + nextRefreshAtFor, +} from "./lib/refreshScheduling.js"; + +export const SYSTEM_OWNER_ID = "system"; + +/** + * Copy a public seed dataset (and its rows) to a new dataset owned by the + * specified user. + * + * Run from the frontend/ directory: + * + * npx convex run scripts:copySeedDatasetsToUser '{"userId": "user_123"}' + * + * Internal mutation (admin-key only): not callable from a browser. + * + * This is idempotent based on seedKey + userId combination: if the user + * already has a copy of a given seed dataset, that dataset is skipped. + */ +export const copySeedDatasetsToUser = internalMutation({ + args: { + userId: v.string(), + }, + handler: async (ctx, args) => { + const { userId } = args; + if (!userId) throw new Error("userId is required"); + + const seedDatasets = await ctx.db + .query("datasets") + .withIndex("by_owner", (q) => q.eq("ownerId", SYSTEM_OWNER_ID)) + .collect(); + + if (seedDatasets.length === 0) { + return { copied: 0, skipped: 0, message: "No seed datasets found" }; + } + + const existingUserDatasets = await ctx.db + .query("datasets") + .withIndex("by_owner", (q) => q.eq("ownerId", userId)) + .collect(); + + const userSeedKeys = new Set( + existingUserDatasets + .filter((d) => d.seedKey) + .map((d) => d.seedKey as string), + ); + + let copied = 0; + let skipped = 0; + + for (const seedDataset of seedDatasets) { + const seedKey = seedDataset.seedKey; + if (!seedKey) { + skipped++; + continue; + } + + if (userSeedKeys.has(seedKey)) { + skipped++; + continue; + } + + const datasetId = await ctx.db.insert("datasets", { + seedKey: seedKey, + name: seedDataset.name, + description: seedDataset.description ?? "", + ownerId: userId, + status: "live", + refreshCadence: seedDataset.refreshCadence ?? "daily", + refreshEnabled: seedDataset.refreshEnabled ?? true, + nextRefreshAt: nextRefreshAtFor( + seedDataset.refreshCadence ?? "daily", + Date.now(), + ), + visibility: "private", + columns: seedDataset.columns ?? [], + rowCount: seedDataset.rowCount ?? 0, + }); + + const seedRows = await ctx.db + .query("datasetRows") + .withIndex("by_dataset", (q) => q.eq("datasetId", seedDataset._id)) + .collect(); + + for (const row of seedRows) { + await ctx.db.insert("datasetRows", { + datasetId, + data: row.data, + sources: row.sources, + rowSummary: row.rowSummary, + howFound: row.howFound, + }); + } + + if (seedRows.length > 0) { + await ctx.db.patch(datasetId, { rowCount: seedRows.length }); + } + + copied++; + } + + return { + copied, + skipped, + total: seedDatasets.length, + }; + }, +}); + +/** + * List all seed datasets (for debugging/verification). + * + * npx convex run scripts:listSeedDatasets + */ +export const listSeedDatasets = internalQuery({ + args: {}, + handler: async (ctx) => { + const seedDatasets = await ctx.db + .query("datasets") + .withIndex("by_owner", (q) => q.eq("ownerId", SYSTEM_OWNER_ID)) + .collect(); + + return seedDatasets.map((d) => ({ + _id: d._id, + seedKey: d.seedKey, + name: d.name, + rowCount: d.rowCount, + })); + }, +}); + +/** + * Delete all datasets (and their rows) for a given user. + * Useful for cleanup/testing. + * + * npx convex run scripts:deleteAllUserDatasets '{"userId": "user_123"}' + */ +export const deleteAllUserDatasets = internalMutation({ + args: { userId: v.string() }, + handler: async (ctx, args) => { + const { userId } = args; + + const userDatasets = await ctx.db + .query("datasets") + .withIndex("by_owner", (q) => q.eq("ownerId", userId)) + .collect(); + + let datasetsDeleted = 0; + let rowsDeleted = 0; + + for (const dataset of userDatasets) { + const rows = await ctx.db + .query("datasetRows") + .withIndex("by_dataset", (q) => q.eq("datasetId", dataset._id)) + .collect(); + + for (const row of rows) { + await ctx.db.delete(row._id); + rowsDeleted++; + } + + await ctx.db.delete(dataset._id); + datasetsDeleted++; + } + + return { datasetsDeleted, rowsDeleted }; + }, +}); diff --git a/frontend/package.json b/frontend/package.json index 60e8d47..8025068 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-window": "^1.8.11", + "sonner": "^2.0.7", "xlsx": "^0.18.5" }, "devDependencies": { From a758300fa2520a051f749e8686360705a83996f8 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Thu, 4 Jun 2026 23:03:18 +0530 Subject: [PATCH 2/6] feat: add filter popover, dataset name editing, and toast notifications --- frontend/app/dataset/[id]/page.tsx | 146 +++++++++- frontend/app/layout.tsx | 2 + frontend/bun.lock | 3 + frontend/components/Toaster.tsx | 79 ++++++ frontend/components/ToasterProvider.tsx | 7 + frontend/components/dataset/FilterPopover.tsx | 251 ++++++++++++++++++ frontend/convex/datasetRows.ts | 57 ++++ frontend/convex/datasets.ts | 21 ++ frontend/package.json | 1 + 9 files changed, 555 insertions(+), 12 deletions(-) create mode 100644 frontend/components/Toaster.tsx create mode 100644 frontend/components/ToasterProvider.tsx 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 d9ee1b6..d7a6b7a 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -13,6 +13,7 @@ import { SideSheet, CellDetail } from "@/components/SideSheet"; import type { DatasetColumn } from "@/components/table/types"; import { useTheme } from "@/components/ThemeToggle"; import { StatusBadge } from "@/components/dataset/StatusBadge"; +import { FilterPopover, ActiveFilter } from "@/components/dataset/FilterPopover"; import { downloadCSV, downloadXLSX } from "@/lib/export"; import { populate, update, stopPopulation } from "@/lib/backend"; import { EVENTS, captureException, track } from "@/lib/analytics"; @@ -22,6 +23,7 @@ import { type RefreshCadence, } from "@/lib/refresh-cadence"; import type { ProfileUser } from "@/lib/profile-user"; +import { toast } from "@/components/Toaster"; export default function DatasetPage() { const params = useParams(); @@ -37,6 +39,19 @@ export default function DatasetPage() { const [settingsOpen, setSettingsOpen] = useState(false); const [confirmPopulate, setConfirmPopulate] = useState(false); const [savingRefreshCadence, setSavingRefreshCadence] = useState(false); + + const updateDetails = useMutation(api.datasets.updateDetails); + + const [filter, setFilter] = useState<{ + column: string; + value: string; + matchType: "contains" | "exact"; + } | null>(null); + + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(""); + const [savingName, setSavingName] = useState(false); + const nameInputRef = useRef(null); const [cellDetail, setCellDetail] = useState<{ column: DatasetColumn; value: unknown; @@ -52,9 +67,17 @@ export default function DatasetPage() { api.datasetRows.listByDataset, authLoading ? "skip" : { datasetId }, ); + const filteredRows = useQuery( + api.datasetRows.listByDatasetFiltered, + authLoading || filter === null + ? "skip" + : { datasetId, filter }, + ); + + const displayRows = filter === null ? (rows ?? []) : (filteredRows ?? []); const updateRefreshSettings = useMutation(api.datasets.updateRefreshSettings); - const rowIds = useMemo(() => (rows ?? []).map((r) => r._id), [rows]); + const rowIds = useMemo(() => (displayRows ?? []).map((r) => r._id), [displayRows]); const selection = useSelection(rowIds); const selectedCount = selection.selected.size; @@ -123,12 +146,12 @@ export default function DatasetPage() { }, [dataset, userId, handlePopulate]); async function handleExport(format: "csv" | "xlsx") { - if (!dataset || !rows || exporting) return; + if (!dataset || !displayRows || exporting) return; const exportRows = selectedCount > 0 - ? rows.filter((r) => selection.selected.has(r._id)) - : rows; + ? displayRows.filter((r) => selection.selected.has(r._id)) + : displayRows; if (exportRows.length === 0) return; setExporting(format); @@ -141,7 +164,7 @@ export default function DatasetPage() { track(EVENTS.DATASET_EXPORTED, { format, row_count: exportRows.length, - total_rows: rows.length, + total_rows: displayRows.length, selected_only: selectedCount > 0, seedKey: dataset.seedKey, }); @@ -196,6 +219,51 @@ export default function DatasetPage() { } } + function handleAddFilter( + column: string, + value: string, + matchType: "contains" | "exact", + ) { + setFilter({ column, value, matchType }); + } + + function handleClearFilter() { + setFilter(null); + } + + function startEditingName() { + if (!dataset || userId !== dataset.ownerId) return; + setNameValue(dataset.name); + setEditingName(true); + setTimeout(() => nameInputRef.current?.select(), 0); + } + + async function saveName() { + if (!dataset || savingName) return; + const trimmed = nameValue.trim(); + if (!trimmed || trimmed === dataset.name) { + setEditingName(false); + return; + } + setSavingName(true); + try { + await updateDetails({ + id: dataset._id, + name: trimmed, + }); + toast.success("Dataset name updated"); + } catch { + toast.error("Failed to update dataset name"); + } finally { + setSavingName(false); + setEditingName(false); + } + } + + function cancelNameEdit() { + setEditingName(false); + } + async function handleRefreshCadenceChange(refreshCadence: RefreshCadence) { if (!dataset || savingRefreshCadence || userId !== dataset.ownerId) return; setSavingRefreshCadence(true); @@ -269,7 +337,7 @@ export default function DatasetPage() { // the "Dataset not found" UI. const exportDisabled = exporting !== null || rows.length === 0; - const isOwner = userId === dataset.ownerId; + const isOwner = userId != null && dataset?.ownerId != null && userId === dataset.ownerId; const displayDataset = { ...dataset, refreshCadence: dataset.refreshCadence ?? "daily", @@ -312,9 +380,46 @@ export default function DatasetPage() { -

- {dataset.name} -

+ {editingName ? ( + setNameValue(e.target.value)} + onBlur={saveName} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + nameInputRef.current?.blur(); + } + if (e.key === "Escape") cancelNameEdit(); + }} + disabled={savingName} + 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 disabled:opacity-50 disabled:cursor-wait" + /> + ) : isOwner ? ( + + ) : ( +

+ {dataset.name} +

+ )}
@@ -370,6 +475,23 @@ export default function DatasetPage() {
+ + + {filter && ( + + )} + +
+

{dataset.description} @@ -389,7 +511,7 @@ export default function DatasetPage() { | )} - {rows.length} rows + {displayRows.length} rows | {dataset.columns.length} columns

@@ -397,7 +519,7 @@ export default function DatasetPage() { { setConfirmPopulate(false); handlePopulate(); diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index c4955a3..3122a20 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { ClerkProvider } from "@clerk/nextjs"; import { ConvexClientProvider } from "./convex-provider"; import { AnalyticsProvider } from "@/lib/analytics-provider"; +import { ToasterProvider } from "@/components/ToasterProvider"; import "./globals.css"; const geistSans = Geist({ @@ -52,6 +53,7 @@ export default function RootLayout({ > {children} + diff --git a/frontend/bun.lock b/frontend/bun.lock index d96b019..0819d99 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -14,6 +14,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-window": "^1.8.11", + "sonner": "^2.0.7", "xlsx": "^0.18.5", }, "devDependencies": { @@ -938,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=="], diff --git a/frontend/components/Toaster.tsx b/frontend/components/Toaster.tsx new file mode 100644 index 0000000..ce2f07a --- /dev/null +++ b/frontend/components/Toaster.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { + CircleCheck, + Info, + Loader2, + OctagonX, + TriangleAlert, +} from "lucide-react"; +import { Toaster as Sonner, toast, type ToasterProps } from "sonner"; + +function getInitialTheme(): "light" | "dark" { + if (typeof window === "undefined") return "light"; + const stored = localStorage.getItem("bigset:theme"); + if (stored === "dark" || stored === "light") return stored; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function useTheme() { + const [theme, setTheme] = useState<"light" | "dark">(() => getInitialTheme()); + + const readTheme = useCallback(() => { + const stored = localStorage.getItem("bigset:theme"); + if (stored === "dark" || stored === "light") return stored as "light" | "dark"; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + }, []); + + useEffect(() => { + const html = document.documentElement; + + const observer = new MutationObserver(() => { + setTheme(readTheme()); + }); + observer.observe(html, { attributes: true, attributeFilter: ["data-theme"] }); + return () => observer.disconnect(); + }, [readTheme]); + + 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/components/ToasterProvider.tsx b/frontend/components/ToasterProvider.tsx new file mode 100644 index 0000000..c6fcb81 --- /dev/null +++ b/frontend/components/ToasterProvider.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { BigSetToaster } from "./Toaster"; + +export function ToasterProvider() { + return ; +} diff --git a/frontend/components/dataset/FilterPopover.tsx b/frontend/components/dataset/FilterPopover.tsx new file mode 100644 index 0000000..8cac94c --- /dev/null +++ b/frontend/components/dataset/FilterPopover.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useEffect, useLayoutEffect, useMemo, 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 wasOpenRef = useRef(false); + + 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. + * Uses useLayoutEffect to detect the transition from open → closed before paint. + */ + useLayoutEffect(() => { + if (open === false && wasOpenRef.current === true) { + setSelectedColumn(""); + setSelectedValue(""); + setMatchType("contains"); + } + wasOpenRef.current = open; + }, [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. + * Memoized to avoid recomputing on every render (e.g., keystroke in search box). + */ + 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; + onFilter(selectedColumn, selectedValue, matchType); + setOpen(false); + } + + return ( +
+ {/* Trigger button */} + + + {/* Popover panel */} + {open && ( +
{ + if (e.key === "Escape") { + setOpen(false); + } + }} + className="absolute top-full left-0 mt-1.5 w-72 bg-surface border border-border rounded-lg shadow-lg z-50" + > + {/* Column + match-type selectors */} +
+ {/* Row: column select + match type select side-by-side */} +
+ + + +
+ + {/* Value input / search area */} + {selectedColumn && ( + <> + {matchType === "contains" ? ( +
+ + setSelectedValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && selectedColumn && selectedValue) { + handleApply(); + } + }} + 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 0536f3b..1370fff 100644 --- a/frontend/convex/datasetRows.ts +++ b/frontend/convex/datasetRows.ts @@ -44,6 +44,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 === undefined || args.filter.value === null || 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 diff --git a/frontend/convex/datasets.ts b/frontend/convex/datasets.ts index 4f34619..470639e 100644 --- a/frontend/convex/datasets.ts +++ b/frontend/convex/datasets.ts @@ -408,6 +408,27 @@ export const updateRefreshSettings = mutation({ }, }); +export const updateDetails = mutation({ + args: { + id: v.id("datasets"), + name: 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); + + const nameChanged = trimmedName !== dataset.name; + const descChanged = args.description !== undefined && args.description !== dataset.description; + if (!nameChanged && !descChanged) return; + + const patch: Partial> = { name: trimmedName }; + if (descChanged) patch.description = args.description; + await ctx.db.patch(dataset._id, patch); + }, +}); + export const backfillRefreshSettings = internalMutation({ args: { defaultCadence: v.optional(refreshCadenceValidator), diff --git a/frontend/package.json b/frontend/package.json index 60e8d47..8025068 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-window": "^1.8.11", + "sonner": "^2.0.7", "xlsx": "^0.18.5" }, "devDependencies": { From af5bf854774af9920fda875c881fd9bf11d3a45d Mon Sep 17 00:00:00 2001 From: Mabud Alam <47685150+MabudAlam@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:07:56 +0530 Subject: [PATCH 3/6] Delete frontend/convex/scripts.ts --- frontend/convex/scripts.ts | 170 ------------------------------------- 1 file changed, 170 deletions(-) delete mode 100644 frontend/convex/scripts.ts diff --git a/frontend/convex/scripts.ts b/frontend/convex/scripts.ts deleted file mode 100644 index 1b9bdae..0000000 --- a/frontend/convex/scripts.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { internalMutation, internalQuery } from "./_generated/server.js"; -import { v } from "convex/values"; -import { - nextRefreshAtFor, -} from "./lib/refreshScheduling.js"; - -export const SYSTEM_OWNER_ID = "system"; - -/** - * Copy a public seed dataset (and its rows) to a new dataset owned by the - * specified user. - * - * Run from the frontend/ directory: - * - * npx convex run scripts:copySeedDatasetsToUser '{"userId": "user_123"}' - * - * Internal mutation (admin-key only): not callable from a browser. - * - * This is idempotent based on seedKey + userId combination: if the user - * already has a copy of a given seed dataset, that dataset is skipped. - */ -export const copySeedDatasetsToUser = internalMutation({ - args: { - userId: v.string(), - }, - handler: async (ctx, args) => { - const { userId } = args; - if (!userId) throw new Error("userId is required"); - - const seedDatasets = await ctx.db - .query("datasets") - .withIndex("by_owner", (q) => q.eq("ownerId", SYSTEM_OWNER_ID)) - .collect(); - - if (seedDatasets.length === 0) { - return { copied: 0, skipped: 0, message: "No seed datasets found" }; - } - - const existingUserDatasets = await ctx.db - .query("datasets") - .withIndex("by_owner", (q) => q.eq("ownerId", userId)) - .collect(); - - const userSeedKeys = new Set( - existingUserDatasets - .filter((d) => d.seedKey) - .map((d) => d.seedKey as string), - ); - - let copied = 0; - let skipped = 0; - - for (const seedDataset of seedDatasets) { - const seedKey = seedDataset.seedKey; - if (!seedKey) { - skipped++; - continue; - } - - if (userSeedKeys.has(seedKey)) { - skipped++; - continue; - } - - const datasetId = await ctx.db.insert("datasets", { - seedKey: seedKey, - name: seedDataset.name, - description: seedDataset.description ?? "", - ownerId: userId, - status: "live", - refreshCadence: seedDataset.refreshCadence ?? "daily", - refreshEnabled: seedDataset.refreshEnabled ?? true, - nextRefreshAt: nextRefreshAtFor( - seedDataset.refreshCadence ?? "daily", - Date.now(), - ), - visibility: "private", - columns: seedDataset.columns ?? [], - rowCount: seedDataset.rowCount ?? 0, - }); - - const seedRows = await ctx.db - .query("datasetRows") - .withIndex("by_dataset", (q) => q.eq("datasetId", seedDataset._id)) - .collect(); - - for (const row of seedRows) { - await ctx.db.insert("datasetRows", { - datasetId, - data: row.data, - sources: row.sources, - rowSummary: row.rowSummary, - howFound: row.howFound, - }); - } - - if (seedRows.length > 0) { - await ctx.db.patch(datasetId, { rowCount: seedRows.length }); - } - - copied++; - } - - return { - copied, - skipped, - total: seedDatasets.length, - }; - }, -}); - -/** - * List all seed datasets (for debugging/verification). - * - * npx convex run scripts:listSeedDatasets - */ -export const listSeedDatasets = internalQuery({ - args: {}, - handler: async (ctx) => { - const seedDatasets = await ctx.db - .query("datasets") - .withIndex("by_owner", (q) => q.eq("ownerId", SYSTEM_OWNER_ID)) - .collect(); - - return seedDatasets.map((d) => ({ - _id: d._id, - seedKey: d.seedKey, - name: d.name, - rowCount: d.rowCount, - })); - }, -}); - -/** - * Delete all datasets (and their rows) for a given user. - * Useful for cleanup/testing. - * - * npx convex run scripts:deleteAllUserDatasets '{"userId": "user_123"}' - */ -export const deleteAllUserDatasets = internalMutation({ - args: { userId: v.string() }, - handler: async (ctx, args) => { - const { userId } = args; - - const userDatasets = await ctx.db - .query("datasets") - .withIndex("by_owner", (q) => q.eq("ownerId", userId)) - .collect(); - - let datasetsDeleted = 0; - let rowsDeleted = 0; - - for (const dataset of userDatasets) { - const rows = await ctx.db - .query("datasetRows") - .withIndex("by_dataset", (q) => q.eq("datasetId", dataset._id)) - .collect(); - - for (const row of rows) { - await ctx.db.delete(row._id); - rowsDeleted++; - } - - await ctx.db.delete(dataset._id); - datasetsDeleted++; - } - - return { datasetsDeleted, rowsDeleted }; - }, -}); From 9a8d217b60030e8ac9b3f2830055cbb3f010ca37 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Thu, 4 Jun 2026 23:24:57 +0530 Subject: [PATCH 4/6] fix: improve theme detection in useTheme hook --- frontend/components/Toaster.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/components/Toaster.tsx b/frontend/components/Toaster.tsx index ce2f07a..c32efe4 100644 --- a/frontend/components/Toaster.tsx +++ b/frontend/components/Toaster.tsx @@ -34,7 +34,12 @@ function useTheme() { const html = document.documentElement; const observer = new MutationObserver(() => { - setTheme(readTheme()); + const attr = html.getAttribute("data-theme"); + if (attr === "light" || attr === "dark") { + setTheme(attr); + } else { + setTheme(readTheme()); + } }); observer.observe(html, { attributes: true, attributeFilter: ["data-theme"] }); return () => observer.disconnect(); From aa9281ed280ce4c86f52279ffef6d4901d1c6fbd Mon Sep 17 00:00:00 2001 From: Edward Tran <130727930+giaphutran12@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:23:54 +0700 Subject: [PATCH 5/6] Fix filtered dataset export and populate count --- frontend/app/dataset/[id]/page.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index c23c45e..48542b8 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -153,12 +153,12 @@ const updateDetails = useMutation(api.datasets.updateDetails); }, [dataset, userId, handlePopulate]); async function handleExport(format: "csv" | "xlsx") { - if (!dataset || !displayRows || exporting) return; + if (!dataset || !rows || exporting) return; const exportRows = selectedCount > 0 ? displayRows.filter((r) => selection.selected.has(r._id)) - : displayRows; + : rows; if (exportRows.length === 0) return; setExporting(format); @@ -171,7 +171,7 @@ const updateDetails = useMutation(api.datasets.updateDetails); track(EVENTS.DATASET_EXPORTED, { format, row_count: exportRows.length, - total_rows: displayRows.length, + total_rows: rows.length, selected_only: selectedCount > 0, seedKey: dataset.seedKey, }); @@ -589,7 +589,7 @@ const updateDetails = useMutation(api.datasets.updateDetails); {confirmPopulate && ( { setConfirmPopulate(false); handlePopulate(); From 2a1d14717825eb0f81fc1f081f9722bf2106876f Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 27 Jun 2026 23:47:54 +0530 Subject: [PATCH 6/6] Fix dataset selection leaking across filter changes Selecting rows then applying or clearing a filter left stale row IDs in the selection, so Update/Export could silently target rows that were no longer visible. - Memoize displayRows so useSelection's row-id deps stay stable. - Clear the selection on filter change via selection.clear (stable callback from the hook), with an eslint-disable for the broader `selection` dep that would re-introduce the render loop. --- frontend/app/dataset/[id]/page.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index 48542b8..1e72ac9 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -75,7 +75,10 @@ const updateDetails = useMutation(api.datasets.updateDetails); : { datasetId, filter }, ); - const displayRows = filter === null ? (rows ?? []) : (filteredRows ?? []); + const displayRows = useMemo( + () => (filter === null ? (rows ?? []) : (filteredRows ?? [])), + [filter, rows, filteredRows], + ); const updateRefreshSettings = useMutation(api.datasets.updateRefreshSettings); const updateMaxRowCount = useMutation(api.datasets.updateMaxRowCount); const usage = useQuery( @@ -87,6 +90,11 @@ const updateDetails = useMutation(api.datasets.updateDetails); const selection = useSelection(rowIds); const selectedCount = selection.selected.size; + useEffect(() => { + selection.clear(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filter, selection.clear]); + const handlePopulate = useCallback(async () => { if (!dataset || populating || dataset.status === "building") return; // A new run is starting — discard any lingering stop-latch from the previous run.