Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 137 additions & 8 deletions frontend/app/dataset/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand All @@ -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<HTMLInputElement>(null);
const [savingMaxRowCount, setSavingMaxRowCount] = useState(false);
const [maxRowCountSaveError, setMaxRowCountSaveError] = useState<string | null>(null);
const [cellDetail, setCellDetail] = useState<{
Expand All @@ -54,17 +68,33 @@ 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(
api.quota.getMy,
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.
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -316,7 +391,7 @@ export default function DatasetPage() {
// the "Dataset not found" UI.

const exportDisabled = exporting !== null || rows.length === 0;

@coderabbitai coderabbitai Bot Jun 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Disable Export based on filtered rows, not unfiltered rows.

Line 339 uses rows.length for exportDisabled, but export actually operates on displayRows (Lines 151-155). With an active filter yielding 0 rows, the button stays enabled and then silently no-ops.

💡 Suggested fix
-  const exportDisabled = exporting !== null || rows.length === 0;
+  const exportDisabled = exporting !== null || displayRows.length === 0;

Also applies to: 151-155

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/app/dataset/`[id]/page.tsx at line 339, exportDisabled currently
uses rows.length but exports act on displayRows; change the condition to use
displayRows.length (i.e., const exportDisabled = exporting !== null ||
displayRows.length === 0) and update any other checks around export logic that
reference rows (see the export-related block around displayRows usage) so the
export button disables when filtered/displayed rows are empty.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think export should export ALL rows, not just the filtered view.
We already have the check boxes to cherry pick rows , we can use that instead for selective export.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Resolve the original review comment
gh pr view 126 --repo tinyfish-io/bigset --json reviews,comments > /dev/null 2>&1

# List review comments by coderabbitai on this PR to find the comment ID to resolve
gh api repos/tinyfish-io/bigset/pulls/126/comments --jq '.[] | select(.user.login == "coderabbitai[bot]") | {id: .id, body: .body[0:80]}'

Length of output: 6098


🏁 Script executed:

#!/bin/bash
# Resolve my original review comment (ID 3357912991)
gh api repos/tinyfish-io/bigset/pulls/126/comments/3357912991/replies \
  --method POST \
  --field body="Acknowledged — resolving this comment. The `rows.length` check is correct given the intended design." > /dev/null 2>&1

# Use the GraphQL API to resolve the review thread
gh api graphql -f query='
mutation {
  resolveReviewThread(input: {threadId: "PRRT_kwDOO91dss6DxmEv"}) {
    thread {
      isResolved
    }
  }
}'

Length of output: 1084


🏁 Script executed:

#!/bin/bash
# Find the correct review thread ID containing comment 3357912991
gh api graphql -f query='
{
  repository(owner: "tinyfish-io", name: "bigset") {
    pullRequest(number: 126) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 1) {
            nodes {
              databaseId
              body
            }
          }
        }
      }
    }
  }
}' | python3 -c "
import sys, json
data = json.load(sys.stdin)
threads = data['data']['repository']['pullRequest']['reviewThreads']['nodes']
for t in threads:
    comments = t['comments']['nodes']
    if comments and comments[0]['databaseId'] == 3357912991:
        print('Thread ID:', t['id'])
        print('isResolved:', t['isResolved'])
        print('Comment body:', comments[0]['body'][:80])
"

Length of output: 1013


🏁 Script executed:

#!/bin/bash
gh api graphql -f query='
mutation {
  resolveReviewThread(input: {threadId: "PRRT_kwDOSd9OX86HJ5BC"}) {
    thread {
      id
      isResolved
    }
  }
}'

Length of output: 677



@MabudAlam, I wasn't able to auto-resolve the thread due to permission constraints — you can resolve it manually on the PR when ready.

To recap the open action item: if you'd like the export behavior to fully match your stated intent (always export all rows when no checkboxes are selected), the one-line change in handleExport is:

const exportRows =
  selectedCount > 0
    ? displayRows.filter((r) => selection.selected.has(r._id))
    : rows ?? [];

Otherwise, no other changes are needed.


🧬 Code Graph Analysis Results

frontend/app/dataset/[id]/page.tsx

Lines 61-82 (row query + filtered display + selection source):

  const datasetId = params.id as Id<"datasets">;
  const dataset = useQuery(
    api.datasets.get,
    authLoading ? "skip" : { id: datasetId },
  );
  const rows = useQuery(
    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(() => (displayRows ?? []).map((r) => r._id), [displayRows]);
  const selection = useSelection(rowIds);
  const selectedCount = selection.selected.size;

Lines 148-183 (export logic uses displayRows + selection):

  async function handleExport(format: "csv" | "xlsx") {
    if (!dataset || !displayRows || exporting) return;

    const exportRows =
      selectedCount > 0
        ? displayRows.filter((r) => selection.selected.has(r._id))
        : displayRows;
    if (exportRows.length === 0) return;

    setExporting(format);
    try {
      if (format === "csv") {
        downloadCSV(dataset.name, dataset.columns, exportRows);
      } else {
        await downloadXLSX(dataset.name, dataset.columns, exportRows);
      }
      track(EVENTS.DATASET_EXPORTED, {
        format,
        row_count: exportRows.length,
        total_rows: displayRows.length,
        selected_only: selectedCount > 0,
        seedKey: dataset.seedKey,
      });
    } catch (err) {
      console.error("[export] failed", err);
      captureException(err, {
        operation: "dataset_export",
        format,
        datasetId: dataset._id,
        row_count: exportRows.length,
        selected_only: selectedCount > 0,
      });
    } finally {
      setExporting(null);
    }
  }

frontend/components/table/use-selection.ts

Lines 28-75 (selection mechanics driven by provided rowIds):

export function useSelection(rowIds: string[]) {
  const [selected, setSelected] = useState<Set<string>>(new Set());

  const has = useCallback((id: string) => selected.has(id), [selected]);

  const toggle = useCallback((id: string) => {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  const toggleAll = useCallback(() => {
    setSelected((prev) =>
      prev.size === rowIds.length ? new Set() : new Set(rowIds),
    );
  }, [rowIds]);

  const shiftToggle = useCallback(
    (id: string) => {
      const idx = rowIds.indexOf(id);
      if (idx === -1) return;
      setSelected((prev) => {
        const next = new Set(prev);
        toggleAdjacent(
          rowIds,
          idx,
          (r) => next.has(r),
          (r) => (next.has(r) ? next.delete(r) : next.add(r)),
        );
        return next;
      });
    },
    [rowIds],
  );

  const clear = useCallback(() => setSelected(new Set()), []);

  const allState: boolean | "indeterminate" = useMemo(() => {
    if (selected.size === 0) return false;
    if (selected.size === rowIds.length) return true;
    return "indeterminate";
  }, [selected.size, rowIds.length]);

  return { selected, has, toggle, toggleAll, shiftToggle, clear, allState };
}

frontend/lib/export.ts

Lines 76-84 (downloadCSV builds CSV from provided rows and triggers download):

export function downloadCSV(
  datasetName: string,
  columns: ExportColumn[],
  rows: ExportRow[],
): void {
  const csv = buildCSV(columns, rows);
  const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
  triggerDownload(blob, safeFilename(datasetName, "csv"));
}

Lines 86-109 (downloadXLSX converts rows into an array-of-arrays sheet):

export async function downloadXLSX(
  datasetName: string,
  columns: ExportColumn[],
  rows: ExportRow[],
): Promise<void> {
  // Dynamic import — keeps the ~700KB xlsx library out of the main bundle.
  const XLSX = await import("xlsx");

  // sheet_aoa expects a 2-D array. First row is headers.
  const aoa: unknown[][] = [
    columns.map((c) => c.name),
    ...rows.map((row) => columns.map((c) => row.data[c.name] ?? "")),
  ];

  const worksheet = XLSX.utils.aoa_to_sheet(aoa);
  const workbook = XLSX.utils.book_new();
  // Sheet names are limited to 31 chars; truncate datasets like
  // "Browser Automation & Web Agent Companies".
  const sheetName =
    datasetName.replace(/[\\/?*[\]:]/g, "-").slice(0, 31) || "Dataset";
  XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);

  XLSX.writeFile(workbook, safeFilename(datasetName, "xlsx"));
}

✏️ Learnings added
Learnt from: MabudAlam
Repo: tinyfish-io/bigset PR: 126
File: frontend/app/dataset/[id]/page.tsx:339-339
Timestamp: 2026-06-04T17:55:40.250Z
Learning: In `frontend/app/dataset/[id]/page.tsx` (Next.js, React, Convex), the export feature is intentionally designed to export ALL unfiltered rows by default. The column filter only affects the display/view. Checkboxes (row selection) are the intended mechanism for selective export. The `exportDisabled` check correctly uses `rows.length` (unfiltered) rather than `displayRows.length`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

const isOwner = userId === dataset.ownerId;
const isOwner = userId != null && dataset?.ownerId != null && userId === dataset.ownerId;
const displayDataset = {
...dataset,
refreshCadence: dataset.refreshCadence ?? "daily",
Expand Down Expand Up @@ -360,9 +435,46 @@ export default function DatasetPage() {
<svg width="8" height="20" viewBox="0 0 8 20" className="text-border shrink-0" aria-hidden="true">
<line x1="7" y1="0" x2="1" y2="20" stroke="currentColor" strokeWidth="1.2" />
</svg>
<h1 className="text-sm font-semibold tracking-tight truncate max-w-md">
{dataset.name}
</h1>
{editingName ? (
<input
ref={nameInputRef}
type="text"
value={nameValue}
onChange={(e) => 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 ? (
<button
onClick={startEditingName}
className="flex items-center gap-1.5 group"
title="Edit dataset name"
>
<h1 className="text-sm font-semibold tracking-tight truncate max-w-md">
{dataset.name}
</h1>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
className="w-3 h-3 text-foreground/30 group-hover:text-foreground/60 transition-colors"
>
<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm1.414 1.06a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 1 .354 0Z" />
</svg>
</button>
) : (
<h1 className="text-sm font-semibold tracking-tight truncate max-w-md">
{dataset.name}
</h1>
)}
<StatusBadge status={dataset.status} />
</div>
<div className="flex items-center gap-1.5">
Expand Down Expand Up @@ -423,6 +535,23 @@ export default function DatasetPage() {
</header>

<div className="border-b border-border px-5 py-2.5 flex items-center gap-4 bg-surface/50 shrink-0">
<FilterPopover
columns={dataset.columns}
rows={rows ?? []}
onFilter={handleAddFilter}
/>

{filter && (
<ActiveFilter
column={filter.column}
value={filter.value}
matchType={filter.matchType}
onClear={handleClearFilter}
/>
)}

<div className="w-px h-4 bg-border shrink-0" />

<div className="min-w-0 flex-1">
<p className="text-xs text-muted truncate">
{dataset.description}
Expand All @@ -442,15 +571,15 @@ export default function DatasetPage() {
<span className="text-foreground/10">|</span>
</>
)}
<span>{rows.length} rows</span>
<span>{displayRows.length} rows</span>
<span className="text-foreground/10">|</span>
<span>{dataset.columns.length} columns</span>
</div>
</div>

<DatasetTable
dataset={displayDataset}
rows={rows}
rows={displayRows}
datasetId={datasetId}
selection={selection}
onCellExpand={handleCellExpand}
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AppAuthProvider } from "@/lib/app-auth";
import { AnalyticsProvider } from "@/lib/analytics-provider";
import { LocalSetupGate } from "./local-setup-gate";
import { ThemeSync } from "@/components/ThemeToggle";
import { ToasterProvider } from "@/components/ToasterProvider";
import "./globals.css";

const geistSans = Geist({
Expand Down Expand Up @@ -51,8 +52,9 @@ export default function RootLayout({
<ThemeSync />
<AppAuthProvider>
<ConvexClientProvider>
<AnalyticsProvider>
<AnalyticsProvider>
<LocalSetupGate>{children}</LocalSetupGate>
<ToasterProvider />
</AnalyticsProvider>
</ConvexClientProvider>
</AppAuthProvider>
Expand Down
3 changes: 3 additions & 0 deletions frontend/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 84 additions & 0 deletions frontend/components/Toaster.tsx
Original file line number Diff line number Diff line change
@@ -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"] });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return () => observer.disconnect();
}, [readTheme]);

return { theme };
}

function BigSetToaster({ ...props }: ToasterProps) {
const { theme } = useTheme();

return (
<Sonner
theme={theme}
className="toaster group"
duration={1000}
icons={{
success: <CircleCheck className="size-4" />,
info: <Info className="size-4" />,
warning: <TriangleAlert className="size-4" />,
error: <OctagonX className="size-4" />,
loading: <Loader2 className="size-4 animate-spin" />,
}}
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 };
7 changes: 7 additions & 0 deletions frontend/components/ToasterProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { BigSetToaster } from "./Toaster";

export function ToasterProvider() {
return <BigSetToaster />;
}
Loading