diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index 697addd..1e72ac9 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -12,6 +12,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 { } from "@/lib/refresh-cadence"; import type { ProfileUser } from "@/lib/profile-user"; import { useAppAuth, useAppClerk, useAppConvexAuth, useAppUser } from "@/lib/app-auth"; +import { toast } from "@/components/Toaster"; export default function DatasetPage() { const params = useParams(); @@ -37,6 +39,18 @@ 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 [savingMaxRowCount, setSavingMaxRowCount] = useState(false); const [maxRowCountSaveError, setMaxRowCountSaveError] = useState(null); const [cellDetail, setCellDetail] = useState<{ @@ -54,6 +68,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 = useMemo( + () => (filter === null ? (rows ?? []) : (filteredRows ?? [])), + [filter, rows, filteredRows], + ); const updateRefreshSettings = useMutation(api.datasets.updateRefreshSettings); const updateMaxRowCount = useMutation(api.datasets.updateMaxRowCount); const usage = useQuery( @@ -61,10 +86,15 @@ export default function DatasetPage() { isAuthenticated ? {} : "skip", ); - 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; + 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. @@ -135,7 +165,7 @@ export default function DatasetPage() { const exportRows = selectedCount > 0 - ? rows.filter((r) => selection.selected.has(r._id)) + ? displayRows.filter((r) => selection.selected.has(r._id)) : rows; if (exportRows.length === 0) return; @@ -204,6 +234,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); @@ -316,7 +391,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", @@ -360,9 +435,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} +

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

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

@@ -450,7 +579,7 @@ export default function DatasetPage() { - + {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..c32efe4 --- /dev/null +++ b/frontend/components/Toaster.tsx @@ -0,0 +1,84 @@ +"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(() => { + 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(); + }, [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 a4a877c..54b4e5b 100644 --- a/frontend/convex/datasetRows.ts +++ b/frontend/convex/datasetRows.ts @@ -60,6 +60,63 @@ export const listByOwnedDatasetInternal = internalQuery({ }, }); +/** + * 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 791671b..997a6d7 100644 --- a/frontend/convex/datasets.ts +++ b/frontend/convex/datasets.ts @@ -480,6 +480,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 updateMaxRowCount = mutation({ args: { id: v.id("datasets"), 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": {