Skip to content
Merged
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
26 changes: 26 additions & 0 deletions frontend/app/dataset/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { DatasetTable } from "@/components/table";
import { useSelection } from "@/components/table/use-selection";
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 { downloadCSV, downloadXLSX } from "@/lib/export";
Expand All @@ -35,6 +37,11 @@ export default function DatasetPage() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [confirmPopulate, setConfirmPopulate] = useState(false);
const [savingRefreshCadence, setSavingRefreshCadence] = useState(false);
const [cellDetail, setCellDetail] = useState<{
column: DatasetColumn;
value: unknown;
sources?: string[];
} | null>(null);

const datasetId = params.id as Id<"datasets">;
const dataset = useQuery(
Expand Down Expand Up @@ -83,6 +90,14 @@ export default function DatasetPage() {
}
}, [dataset, populating, getToken]);

const handleCellExpand = useCallback((columnName: string, value: unknown, rowId: string) => {
if (!dataset || !rows) return;
const col = dataset.columns.find((c) => c.name === columnName);
if (!col) return;
const row = rows.find((r) => r._id === rowId);
setCellDetail({ column: col, value, sources: row?.sources });
}, [dataset, rows]);

const openedFired = useRef<string | null>(null);
const autoPopulateFired = useRef<string | null>(null);
useEffect(() => {
Expand Down Expand Up @@ -385,8 +400,19 @@ export default function DatasetPage() {
rows={rows}
datasetId={datasetId}
selection={selection}
onCellExpand={handleCellExpand}
/>

<SideSheet open={cellDetail !== null} onClose={() => setCellDetail(null)}>
{cellDetail && (
<CellDetail
column={cellDetail.column}
value={cellDetail.value}
sources={cellDetail.sources}
/>
)}
</SideSheet>

{confirmPopulate && (
<ConfirmPopulateModal
rowCount={rows.length}
Expand Down
228 changes: 228 additions & 0 deletions frontend/components/SideSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
"use client";

import { useEffect, useRef, useState, useCallback } from "react";
import type { DatasetColumn } from "@/components/table/types";

function IconX() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
);
}
function IconCopy() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</svg>
);
}
function IconCheck() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 6 9 17l-5-5"/>
</svg>
);
}
function IconExternalLink() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
</svg>
);
}

/* ------------------------------------------------------------------ */
/* Shell */
/* ------------------------------------------------------------------ */

interface SideSheetProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}

export function SideSheet({ open, onClose, children }: SideSheetProps) {
const panelRef = useRef<HTMLDivElement>(null);

// Lock body scroll while open.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}, [open]);

// Keyboard: Escape closes, Tab stays trapped inside.
useEffect(() => {
if (!open || !panelRef.current) return;
const panel = panelRef.current;

const focusable = panel.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
focusable[0]?.focus();

function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { onClose(); return; }
if (e.key !== "Tab" || focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
}

panel.addEventListener("keydown", onKey);
return () => panel.removeEventListener("keydown", onKey);
}, [open, onClose]);

if (!open) return null;

return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true">
{/* Backdrop */}
<div
className="absolute inset-0 bg-foreground/20 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div
ref={panelRef}
aria-labelledby="cell-detail-title"
className="relative w-full max-w-lg max-h-[80vh] bg-surface border border-border rounded-xl shadow-2xl flex flex-col"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
<h2 id="cell-detail-title" className="text-sm font-semibold text-foreground">Cell Detail</h2>
<button
onClick={onClose}
className="inline-flex items-center justify-center h-7 w-7 text-muted hover:text-foreground transition-colors rounded"
aria-label="Close"
>
<IconX />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">{children}</div>
</div>
</div>
);
}

/* ------------------------------------------------------------------ */
/* Content */
/* ------------------------------------------------------------------ */

interface CellDetailProps {
column: DatasetColumn;
value: unknown;
/** Row-level sources stored by the populate agent. */
sources?: string[];
}

function isValidHttpUrl(src: string): boolean {
try {
const { protocol } = new URL(src);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}

export function CellDetail({ column, value, sources }: CellDetailProps) {
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const displayValue = value == null || value === "" ? "—" : String(value);

// Clear any pending timer when the component unmounts to avoid calling
// setCopied on an already-gone component.
useEffect(() => {
return () => {
if (copyTimerRef.current != null) clearTimeout(copyTimerRef.current);
};
}, []);

const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value == null ? "" : String(value));
setCopied(true);
if (copyTimerRef.current != null) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API unavailable (e.g. non-HTTPS dev); silently ignore.
}
}, [value]);

return (
<div className="space-y-6">
{/* Column name + description */}
<div>
<p className="text-sm font-semibold text-foreground">{column.name}</p>
{column.description && (
<p className="text-xs text-muted mt-0.5">{column.description}</p>
)}
</div>

{/* Value */}
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[11px] font-medium text-muted uppercase tracking-wide">
Value <span className="normal-case">({column.type})</span>
</p>
<button
type="button"
onClick={() => { void handleCopy(); }}
className="inline-flex items-center gap-1.5 text-[11px] font-medium text-muted hover:text-foreground transition-colors"
aria-label="Copy value"
>
{copied
? <><span className="text-green-500"><IconCheck /></span><span className="text-green-500">Copied</span></>
: <><IconCopy /><span>Copy</span></>
}
</button>
</div>
<div
className="rounded-lg border border-border bg-background px-4 py-3"
data-ph-mask-text="true"
>
<p className="text-sm text-foreground break-all whitespace-pre-wrap">
{displayValue}
</p>
</div>
</div>

{/* Sources */}
{sources && sources.length > 0 && (
<div>
<p className="text-[11px] font-medium text-muted uppercase tracking-wide mb-1.5">
Sources
</p>
<ul className="space-y-1.5">
{sources.map((src, i) => (
<li key={src || i}>
{isValidHttpUrl(src) ? (
<a
href={src}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-1.5 text-xs text-link hover:underline break-all"
data-ph-mask-text="true"
>
<span className="mt-0.5 shrink-0"><IconExternalLink /></span>
{src}
</a>
) : (
<span className="flex items-start gap-1.5 text-xs text-muted break-all" data-ph-mask-text="true">
<span className="mt-0.5 shrink-0"><IconExternalLink /></span>
{src}
</span>
)}
</li>
))}
</ul>
</div>
)}
</div>
);
}
24 changes: 22 additions & 2 deletions frontend/components/table/DataRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,21 @@ import type { DatasetRow, DatasetColumn } from "./types";
import { CellValue } from "./CellValue";
import { floorWidth } from "./utils";

function IconMaximize2() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
);
}

export interface DataRowData {
rows: Row<DatasetRow>[];
columns: DatasetColumn[];
columnWidths: number[];
isSelected: (id: string) => boolean;
toggleRow: (id: string, shiftKey: boolean) => void;
onCellExpand: (columnName: string, value: unknown, rowId: string) => void;
isBuilding: boolean;
pendingRowIds: Set<string>;
flashingCells: Set<string>;
Expand All @@ -27,7 +36,7 @@ function DataRowImpl({
index: number;
style: CSSProperties;
}) {
const { rows, columns, columnWidths, isSelected, toggleRow, isBuilding, pendingRowIds, flashingCells } = data;
const { rows, columns, columnWidths, isSelected, toggleRow, onCellExpand, isBuilding, pendingRowIds, flashingCells } = data;
const row = rows[index];

if (!row) {
Expand Down Expand Up @@ -112,7 +121,7 @@ function DataRowImpl({
<div
key={col.name}
data-ph-mask-text="true"
className={`relative shrink-0 overflow-hidden text-ellipsis whitespace-nowrap border-r border-border/30 last:border-r-0 ${
className={`group relative shrink-0 overflow-hidden text-ellipsis whitespace-nowrap border-r border-border/30 last:border-r-0 ${
cellIdx === 0
? "font-medium text-foreground"
: "text-foreground/70"
Expand All @@ -123,6 +132,17 @@ function DataRowImpl({
}}
>
<CellValue value={value} type={col.type} />
<button
type="button"
onClick={(e) => {
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 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground p-0.5 rounded bg-foreground/5 hover:bg-foreground/10 text-muted hover:text-foreground transition-all"
aria-label={`Expand ${col.name}`}
>
<IconMaximize2 />
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{isPending && <div className="shimmer-overlay absolute inset-0" />}
</div>
);
Expand Down
5 changes: 4 additions & 1 deletion frontend/components/table/DatasetTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,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<HTMLDivElement>(null);
const previousResizingColumnIdRef = useRef<string | false>(false);
Expand Down Expand Up @@ -168,11 +170,12 @@ export function DatasetTable({
columnWidths,
isSelected: selection.has,
toggleRow,
onCellExpand,
isBuilding,
pendingRowIds,
flashingCells,
}),
[tableRows, dataset.columns, columnWidths, selection.has, toggleRow, isBuilding, pendingRowIds, flashingCells],
[tableRows, dataset.columns, columnWidths, selection.has, toggleRow, onCellExpand, isBuilding, pendingRowIds, flashingCells],
);

return (
Expand Down
1 change: 1 addition & 0 deletions frontend/components/table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ export interface DatasetRow {
_id: string;
_creationTime: number;
data: Record<string, unknown>;
sources?: string[];
updateStatus?: "pending";
}