Compare commits

..

1 Commits

Author SHA1 Message Date
Raunak Bhagat
7425c59e39 refactor(fe): move table component to opal and update size API
Move the table component from `src/refresh-components/table/` to
`lib/opal/src/components/table/` following the opal component pattern.
Rename the main export from `DataTable` to `Table`, accessible via
`import { Table, createTableColumns } from "@opal/components"`.

Migrate the size system from `"regular" | "small"` to `"md" | "lg"`
(using `SizeVariants` from opal types) across all internal components,
CSS, and documentation.

Also adds `size`, `variant`, `selectionBehavior`, `qualifier`, and
`heightVariant` props to the base `<table>` element wrapper (data
attributes only — wiring to follow in subsequent commits).
2026-03-17 20:15:30 -07:00
34 changed files with 444 additions and 1035 deletions

View File

@@ -479,9 +479,7 @@ def is_zip_file(file: UploadFile) -> bool:
def upload_files(
files: list[UploadFile],
file_origin: FileOrigin = FileOrigin.CONNECTOR,
unzip: bool = True,
files: list[UploadFile], file_origin: FileOrigin = FileOrigin.CONNECTOR
) -> FileUploadResponse:
# Skip directories and known macOS metadata entries
@@ -504,46 +502,31 @@ def upload_files(
if seen_zip:
raise HTTPException(status_code=400, detail=SEEN_ZIP_DETAIL)
seen_zip = True
# Validate the zip by opening it (catches corrupt/non-zip files)
with zipfile.ZipFile(file.file, "r") as zf:
if unzip:
zip_metadata_file_id = save_zip_metadata_to_file_store(
zf, file_store
zip_metadata_file_id = save_zip_metadata_to_file_store(
zf, file_store
)
for file_info in zf.namelist():
if zf.getinfo(file_info).is_dir():
continue
if not should_process_file(file_info):
continue
sub_file_bytes = zf.read(file_info)
mime_type, __ = mimetypes.guess_type(file_info)
if mime_type is None:
mime_type = "application/octet-stream"
file_id = file_store.save_file(
content=BytesIO(sub_file_bytes),
display_name=os.path.basename(file_info),
file_origin=file_origin,
file_type=mime_type,
)
for file_info in zf.namelist():
if zf.getinfo(file_info).is_dir():
continue
if not should_process_file(file_info):
continue
sub_file_bytes = zf.read(file_info)
mime_type, __ = mimetypes.guess_type(file_info)
if mime_type is None:
mime_type = "application/octet-stream"
file_id = file_store.save_file(
content=BytesIO(sub_file_bytes),
display_name=os.path.basename(file_info),
file_origin=file_origin,
file_type=mime_type,
)
deduped_file_paths.append(file_id)
deduped_file_names.append(os.path.basename(file_info))
continue
# Store the zip as-is (unzip=False)
file.file.seek(0)
file_id = file_store.save_file(
content=file.file,
display_name=file.filename,
file_origin=file_origin,
file_type=file.content_type or "application/zip",
)
deduped_file_paths.append(file_id)
deduped_file_names.append(file.filename)
deduped_file_paths.append(file_id)
deduped_file_names.append(os.path.basename(file_info))
continue
# Since we can't render docx files in the UI,
@@ -630,10 +613,9 @@ def _fetch_and_check_file_connector_cc_pair_permissions(
@router.post("/admin/connector/file/upload", tags=PUBLIC_API_TAGS)
def upload_files_api(
files: list[UploadFile],
unzip: bool = True,
_: User = Depends(current_curator_or_admin_user),
) -> FileUploadResponse:
return upload_files(files, FileOrigin.OTHER, unzip=unzip)
return upload_files(files, FileOrigin.OTHER)
@router.get("/admin/connector/{connector_id}/files", tags=PUBLIC_API_TAGS)

View File

@@ -1,109 +0,0 @@
import io
import zipfile
from unittest.mock import MagicMock
from unittest.mock import patch
from zipfile import BadZipFile
import pytest
from fastapi import UploadFile
from starlette.datastructures import Headers
from onyx.configs.constants import FileOrigin
from onyx.server.documents.connector import upload_files
def _create_test_zip() -> bytes:
"""Create a simple in-memory zip file containing two text files."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("file1.txt", "hello")
zf.writestr("file2.txt", "world")
return buf.getvalue()
def _make_upload_file(content: bytes, filename: str, content_type: str) -> UploadFile:
return UploadFile(
file=io.BytesIO(content),
filename=filename,
headers=Headers({"content-type": content_type}),
)
@patch("onyx.server.documents.connector.get_default_file_store")
def test_upload_zip_with_unzip_true_extracts_files(
mock_get_store: MagicMock,
) -> None:
"""When unzip=True (default), a zip upload is extracted into individual files."""
mock_store = MagicMock()
mock_store.save_file.side_effect = lambda **kwargs: f"id-{kwargs['display_name']}"
mock_get_store.return_value = mock_store
zip_bytes = _create_test_zip()
upload = _make_upload_file(zip_bytes, "test.zip", "application/zip")
result = upload_files([upload], FileOrigin.CONNECTOR)
# Should have extracted the two individual files, not stored the zip itself
assert len(result.file_paths) == 2
assert "id-file1.txt" in result.file_paths
assert "id-file2.txt" in result.file_paths
assert "file1.txt" in result.file_names
assert "file2.txt" in result.file_names
@patch("onyx.server.documents.connector.get_default_file_store")
def test_upload_zip_with_unzip_false_stores_zip_as_is(
mock_get_store: MagicMock,
) -> None:
"""When unzip=False, the zip file is stored as-is without extraction."""
mock_store = MagicMock()
mock_store.save_file.return_value = "zip-file-id"
mock_get_store.return_value = mock_store
zip_bytes = _create_test_zip()
upload = _make_upload_file(zip_bytes, "site_export.zip", "application/zip")
result = upload_files([upload], FileOrigin.CONNECTOR, unzip=False)
# Should store exactly one file (the zip itself)
assert len(result.file_paths) == 1
assert result.file_paths[0] == "zip-file-id"
assert result.file_names == ["site_export.zip"]
# No zip metadata should be created
assert result.zip_metadata_file_id is None
# Verify the stored content is a valid zip
saved_content: io.BytesIO = mock_store.save_file.call_args[1]["content"]
saved_content.seek(0)
with zipfile.ZipFile(saved_content, "r") as zf:
assert set(zf.namelist()) == {"file1.txt", "file2.txt"}
@patch("onyx.server.documents.connector.get_default_file_store")
def test_upload_invalid_zip_with_unzip_false_raises(
mock_get_store: MagicMock,
) -> None:
"""An invalid zip is rejected even when unzip=False (validation still runs)."""
mock_get_store.return_value = MagicMock()
bad_zip = _make_upload_file(b"not a zip", "bad.zip", "application/zip")
with pytest.raises(BadZipFile):
upload_files([bad_zip], FileOrigin.CONNECTOR, unzip=False)
@patch("onyx.server.documents.connector.get_default_file_store")
def test_upload_multiple_zips_rejected_when_unzip_false(
mock_get_store: MagicMock,
) -> None:
"""The seen_zip guard rejects a second zip even when unzip=False."""
mock_store = MagicMock()
mock_store.save_file.return_value = "zip-id"
mock_get_store.return_value = mock_store
zip_bytes = _create_test_zip()
zip1 = _make_upload_file(zip_bytes, "a.zip", "application/zip")
zip2 = _make_upload_file(zip_bytes, "b.zip", "application/zip")
with pytest.raises(Exception, match="Only one zip file"):
upload_files([zip1, zip2], FileOrigin.CONNECTOR, unzip=False)

View File

@@ -52,3 +52,10 @@ export {
type PaginationProps,
type PaginationSize,
} from "@opal/components/pagination/components";
/* Table */
export { Table } from "@opal/components/table/components";
export { createTableColumns } from "@opal/components/table/columns";
export type { DataTableProps } from "@opal/components/table/types";

View File

@@ -1,5 +1,5 @@
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
interface ActionsContainerProps {
type: "head" | "cell";

View File

@@ -20,13 +20,13 @@ import Divider from "@/refresh-components/Divider";
interface ColumnVisibilityPopoverProps<TData extends RowData = RowData> {
table: Table<TData>;
columnVisibility: VisibilityState;
size?: "regular" | "small";
size?: "md" | "lg";
}
function ColumnVisibilityPopover<TData extends RowData>({
table,
columnVisibility,
size = "regular",
size = "lg",
}: ColumnVisibilityPopoverProps<TData>) {
const [open, setOpen] = useState(false);
const hideableColumns = table
@@ -39,8 +39,8 @@ function ColumnVisibilityPopover<TData extends RowData>({
<Button
icon={SvgColumn}
interaction={open ? "hover" : "rest"}
size={size === "small" ? "sm" : "md"}
prominence="internal"
size={size === "md" ? "sm" : "md"}
prominence="tertiary"
tooltip="Columns"
/>
</Popover.Trigger>
@@ -80,7 +80,7 @@ function ColumnVisibilityPopover<TData extends RowData>({
// ---------------------------------------------------------------------------
interface CreateColumnVisibilityColumnOptions {
size?: "regular" | "small";
size?: "md" | "lg";
}
function createColumnVisibilityColumn<TData>(

View File

@@ -1,14 +1,11 @@
import { memo } from "react";
import { type Row, flexRender } from "@tanstack/react-table";
import TableRow from "@/refresh-components/table/TableRow";
import TableCell from "@/refresh-components/table/TableCell";
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
import type {
OnyxColumnDef,
OnyxQualifierColumn,
} from "@/refresh-components/table/types";
import TableRow from "./TableRow";
import TableCell from "./TableCell";
import QualifierContainer from "./QualifierContainer";
import TableQualifier from "./TableQualifier";
import ActionsContainer from "./ActionsContainer";
import type { OnyxColumnDef, OnyxQualifierColumn } from "./types";
interface DragOverlayRowProps<TData> {
row: Row<TData>;

View File

@@ -1,10 +1,10 @@
"use client";
import { cn } from "@/lib/utils";
import { cn } from "@opal/utils";
import { Button, Pagination } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import { SvgEye, SvgXCircle } from "@opal/icons";
import type { ReactNode } from "react";
@@ -41,7 +41,7 @@ interface FooterSelectionModeProps {
totalPages: number;
/** Called when the user navigates to a different page. */
onPageChange: (page: number) => void;
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
/** Controls overall footer sizing. `"lg"` (default) or `"md"`. */
size?: TableSize;
className?: string;
}
@@ -100,7 +100,7 @@ function getSelectionMessage(
*/
export default function Footer(props: FooterProps) {
const resolvedSize = useTableSize();
const isSmall = resolvedSize === "small";
const isSmall = resolvedSize === "md";
return (
<div
className={cn(

View File

@@ -1,5 +1,5 @@
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
interface QualifierContainerProps {
type: "head" | "cell";

View File

@@ -0,0 +1,82 @@
# Table
Config-driven table component with sorting, pagination, column visibility,
row selection, drag-and-drop reordering, and server-side mode.
## Usage
```tsx
import { Table, createTableColumns } from "@opal/components";
interface User {
id: string;
email: string;
name: string | null;
status: "active" | "invited";
}
const tc = createTableColumns<User>();
const columns = [
tc.qualifier({ content: "avatar-user", getInitials: (r) => r.name?.[0] ?? "?" }),
tc.column("email", {
header: "Name",
weight: 22,
minWidth: 140,
cell: (email, row) => <span>{row.name ?? email}</span>,
}),
tc.column("status", {
header: "Status",
weight: 14,
cell: (status) => <span>{status}</span>,
}),
tc.actions(),
];
function UsersTable({ users }: { users: User[] }) {
return (
<Table
data={users}
columns={columns}
getRowId={(r) => r.id}
pageSize={10}
footer={{ mode: "summary" }}
/>
);
}
```
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `data` | `TData[]` | required | Row data array |
| `columns` | `OnyxColumnDef<TData>[]` | required | Column definitions from `createTableColumns()` |
| `getRowId` | `(row: TData) => string` | required | Unique row identifier |
| `pageSize` | `number` | `10` | Rows per page (`Infinity` disables pagination) |
| `size` | `"md" \| "lg"` | `"lg"` | Density variant |
| `footer` | `DataTableFooterConfig` | — | Footer mode (`"selection"` or `"summary"`) |
| `initialSorting` | `SortingState` | — | Initial sort state |
| `initialColumnVisibility` | `VisibilityState` | — | Initial column visibility |
| `draggable` | `DataTableDraggableConfig` | — | Enable drag-and-drop reordering |
| `onSelectionChange` | `(ids: string[]) => void` | — | Selection callback |
| `onRowClick` | `(row: TData) => void` | — | Row click handler |
| `searchTerm` | `string` | — | Global text filter |
| `height` | `number \| string` | — | Max scrollable height |
| `headerBackground` | `string` | — | Sticky header background |
| `serverSide` | `ServerSideConfig` | — | Server-side pagination/sorting/filtering |
| `emptyState` | `ReactNode` | — | Empty state content |
## Column Builder
`createTableColumns<TData>()` returns a builder with:
- `tc.qualifier(opts)` — leading avatar/icon/checkbox column
- `tc.column(accessor, opts)` — data column with sorting/resizing
- `tc.display(opts)` — non-accessor custom column
- `tc.actions(opts)` — trailing actions column with visibility/sorting popovers
## Footer Modes
- **`"selection"`** — shows selection count, optional view/clear buttons, count pagination
- **`"summary"`** — shows "Showing X~Y of Z", list pagination, optional extra element

View File

@@ -21,7 +21,7 @@ import Text from "@/refresh-components/texts/Text";
interface SortingPopoverProps<TData extends RowData = RowData> {
table: Table<TData>;
sorting: SortingState;
size?: "regular" | "small";
size?: "md" | "lg";
footerText?: string;
ascendingLabel?: string;
descendingLabel?: string;
@@ -30,7 +30,7 @@ interface SortingPopoverProps<TData extends RowData = RowData> {
function SortingPopover<TData extends RowData>({
table,
sorting,
size = "regular",
size = "lg",
footerText,
ascendingLabel = "Ascending",
descendingLabel = "Descending",
@@ -48,8 +48,8 @@ function SortingPopover<TData extends RowData>({
<Button
icon={currentSort === null ? SvgArrowUpDown : SvgSortOrder}
interaction={open ? "hover" : "rest"}
size={size === "small" ? "sm" : "md"}
prominence="internal"
size={size === "md" ? "sm" : "md"}
prominence="tertiary"
tooltip="Sort"
/>
</Popover.Trigger>
@@ -149,7 +149,7 @@ function SortingPopover<TData extends RowData>({
// ---------------------------------------------------------------------------
interface CreateSortingColumnOptions {
size?: "regular" | "small";
size?: "md" | "lg";
footerText?: string;
ascendingLabel?: string;
descendingLabel?: string;

View File

@@ -0,0 +1,148 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Table, createTableColumns } from "@opal/components";
// ---------------------------------------------------------------------------
// Sample data
// ---------------------------------------------------------------------------
interface User {
id: string;
email: string;
name: string;
role: "admin" | "user" | "viewer";
status: "active" | "invited" | "inactive";
}
const USERS: User[] = [
{
id: "1",
email: "alice@example.com",
name: "Alice Johnson",
role: "admin",
status: "active",
},
{
id: "2",
email: "bob@example.com",
name: "Bob Smith",
role: "user",
status: "active",
},
{
id: "3",
email: "carol@example.com",
name: "Carol White",
role: "viewer",
status: "invited",
},
{
id: "4",
email: "dave@example.com",
name: "Dave Brown",
role: "user",
status: "inactive",
},
{
id: "5",
email: "eve@example.com",
name: "Eve Davis",
role: "admin",
status: "active",
},
{
id: "6",
email: "frank@example.com",
name: "Frank Miller",
role: "viewer",
status: "active",
},
{
id: "7",
email: "grace@example.com",
name: "Grace Lee",
role: "user",
status: "invited",
},
{
id: "8",
email: "hank@example.com",
name: "Hank Wilson",
role: "user",
status: "active",
},
{
id: "9",
email: "iris@example.com",
name: "Iris Taylor",
role: "viewer",
status: "active",
},
{
id: "10",
email: "jack@example.com",
name: "Jack Moore",
role: "admin",
status: "active",
},
{
id: "11",
email: "kate@example.com",
name: "Kate Anderson",
role: "user",
status: "inactive",
},
{
id: "12",
email: "leo@example.com",
name: "Leo Thomas",
role: "viewer",
status: "active",
},
];
// ---------------------------------------------------------------------------
// Columns
// ---------------------------------------------------------------------------
const tc = createTableColumns<User>();
const columns = [
tc.qualifier({
content: "avatar-user",
getInitials: (r) =>
r.name
.split(" ")
.map((n) => n[0])
.join(""),
}),
tc.column("name", { header: "Name", weight: 25, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 30, minWidth: 160 }),
tc.column("role", { header: "Role", weight: 15, minWidth: 80 }),
tc.column("status", { header: "Status", weight: 15, minWidth: 80 }),
tc.actions(),
];
// ---------------------------------------------------------------------------
// Story
// ---------------------------------------------------------------------------
const meta: Meta<typeof Table> = {
title: "opal/components/Table",
component: Table,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Table>;
export const Default: Story = {
render: () => (
<Table
data={USERS}
columns={columns}
getRowId={(r) => r.id}
pageSize={8}
footer={{ mode: "summary" }}
/>
),
};

View File

@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { cn } from "@opal/utils";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import type { WithoutStyles } from "@/types";
interface TableCellProps

View File

@@ -0,0 +1,70 @@
import { cn } from "@opal/utils";
import type { WithoutStyles } from "@/types";
import type { ExtremaSizeVariants, SizeVariants } from "@opal/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type TableSize = Extract<SizeVariants, "md" | "lg">;
type TableVariant = "rows" | "card";
type TableQualifier = null | "icon" | "checkbox";
type SelectionBehavior = "no-select" | "single-select" | "multi-select";
interface TableProps
extends WithoutStyles<React.TableHTMLAttributes<HTMLTableElement>> {
ref?: React.Ref<HTMLTableElement>;
/** Size preset for the table. @default "lg" */
size?: TableSize;
/** Visual row variant. @default "card" */
variant?: TableVariant;
/** Row selection behavior. @default "no-select" */
selectionBehavior?: SelectionBehavior;
/** Leading qualifier column type. @default null */
qualifier?: TableQualifier;
/** Height behavior. `"fit"` = shrink to content, `"full"` = fill available space. */
heightVariant?: ExtremaSizeVariants;
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
* When provided the table uses exactly this width instead of stretching
* to fill its container, which prevents `table-layout: fixed` from
* redistributing extra space across columns on resize. */
width?: number;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function Table({
ref,
size = "lg",
variant = "card",
selectionBehavior = "no-select",
qualifier = null,
heightVariant,
width,
...props
}: TableProps) {
return (
<table
ref={ref}
className={cn("border-separate border-spacing-0", "min-w-full")}
style={{ tableLayout: "fixed", width: width ?? undefined }}
data-size={size}
data-variant={variant}
data-selection={selectionBehavior}
data-qualifier={qualifier ?? undefined}
data-height={heightVariant}
{...props}
/>
);
}
export default Table;
export type {
TableProps,
TableSize,
TableVariant,
TableQualifier,
SelectionBehavior,
};

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";
import { cn } from "@opal/utils";
import Text from "@/refresh-components/texts/Text";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import type { WithoutStyles } from "@/types";
import { Button } from "@opal/components";
import { SvgChevronDown, SvgChevronUp, SvgHandle, SvgSort } from "@opal/icons";
@@ -30,7 +30,7 @@ interface TableHeadCustomProps {
icon?: (sorted: SortDirection) => IconFunctionComponent;
/** Text alignment for the column. Defaults to `"left"`. */
alignment?: "left" | "center" | "right";
/** Cell density. `"small"` uses tighter padding for denser layouts. */
/** Cell density. `"md"` uses tighter padding for denser layouts. */
size?: TableSize;
/** Column width in pixels. Applied as an inline style on the `<th>`. */
width?: number;
@@ -88,7 +88,7 @@ export default function TableHead({
}: TableHeadProps) {
const contextSize = useTableSize();
const resolvedSize = size ?? contextSize;
const isSmall = resolvedSize === "small";
const isSmall = resolvedSize === "md";
return (
<th
{...thProps}

View File

@@ -1,12 +1,12 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { cn } from "@opal/utils";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import { SvgUser } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import type { QualifierContentType } from "@/refresh-components/table/types";
import type { QualifierContentType } from "./types";
import Checkbox from "@/refresh-components/inputs/Checkbox";
import Text from "@/refresh-components/texts/Text";
@@ -35,8 +35,8 @@ interface TableQualifierProps {
}
const iconSizes = {
regular: 16,
small: 14,
lg: 16,
md: 14,
} as const;
function getQualifierStyles(selected: boolean, disabled: boolean) {
@@ -115,7 +115,7 @@ function TableQualifier({
<div
className={cn(
"flex items-center justify-center rounded-full bg-background-neutral-inverted-00",
resolvedSize === "regular" ? "h-7 w-7" : "h-6 w-6"
resolvedSize === "lg" ? "h-7 w-7" : "h-6 w-6"
)}
>
<Text
@@ -138,7 +138,7 @@ function TableQualifier({
<div
className={cn(
"group relative inline-flex shrink-0 items-center justify-center",
resolvedSize === "regular" ? "h-9 w-9" : "h-7 w-7",
resolvedSize === "lg" ? "h-9 w-9" : "h-7 w-7",
disabled ? "cursor-not-allowed" : "cursor-default",
className
)}
@@ -147,7 +147,7 @@ function TableQualifier({
<div
className={cn(
"flex items-center justify-center overflow-hidden transition-colors",
resolvedSize === "regular" ? "h-9 w-9" : "h-7 w-7",
resolvedSize === "lg" ? "h-9 w-9" : "h-7 w-7",
isRound ? "rounded-full" : "rounded-08",
styles.container,
content === "image" && disabled && !selected && "opacity-50"

View File

@@ -1,8 +1,8 @@
"use client";
import { cn } from "@/lib/utils";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { cn } from "@opal/utils";
import { useTableSize } from "./TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import type { WithoutStyles } from "@/types";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -95,7 +95,7 @@ function SortableTableRow({
{...listeners}
>
<SvgHandle
size={resolvedSize === "small" ? 12 : 16}
size={resolvedSize === "md" ? 12 : 16}
className="text-border-02"
/>
</button>

View File

@@ -1,10 +1,11 @@
"use client";
import { createContext, useContext } from "react";
import type { SizeVariants } from "@opal/types";
type TableSize = "regular" | "small";
type TableSize = Extract<SizeVariants, "md" | "lg">;
const TableSizeContext = createContext<TableSize>("regular");
const TableSizeContext = createContext<TableSize>("lg");
interface TableSizeProviderProps {
size: TableSize;

View File

@@ -13,10 +13,10 @@ import type {
OnyxDataColumn,
OnyxDisplayColumn,
OnyxActionsColumn,
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
} from "./types";
import type { TableSize } from "./TableSizeContext";
import type { IconFunctionComponent } from "@opal/types";
import type { SortDirection } from "@/refresh-components/table/TableHead";
import type { SortDirection } from "./TableHead";
// ---------------------------------------------------------------------------
// Qualifier column config
@@ -160,7 +160,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
id: "qualifier",
def,
width: (size: TableSize) =>
size === "small" ? { fixed: 40 } : { fixed: 56 },
size === "md" ? { fixed: 40 } : { fixed: 56 },
content,
headerContentType: config?.headerContentType,
getInitials: config?.getInitials,
@@ -246,7 +246,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
id: "__actions",
def,
width: (size: TableSize) =>
size === "small" ? { fixed: 20 } : { fixed: 88 },
size === "md" ? { fixed: 20 } : { fixed: 88 },
showColumnVisibility: config?.showColumnVisibility ?? true,
showSorting: config?.showSorting ?? true,
sortingFooterText: config?.sortingFooterText,

View File

@@ -1,30 +1,30 @@
"use client";
"use no memo";
import "@opal/components/table/styles.css";
import { useEffect, useMemo } from "react";
import { flexRender } from "@tanstack/react-table";
import useDataTable, {
toOnyxSortDirection,
} from "@/refresh-components/table/hooks/useDataTable";
import useColumnWidths from "@/refresh-components/table/hooks/useColumnWidths";
import useDraggableRows from "@/refresh-components/table/hooks/useDraggableRows";
import Table from "@/refresh-components/table/Table";
import TableHeader from "@/refresh-components/table/TableHeader";
import TableBody from "@/refresh-components/table/TableBody";
import TableRow from "@/refresh-components/table/TableRow";
import TableHead from "@/refresh-components/table/TableHead";
import TableCell from "@/refresh-components/table/TableCell";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
import DragOverlayRow from "@/refresh-components/table/DragOverlayRow";
import Footer from "@/refresh-components/table/Footer";
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibilityPopover";
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
import type { WidthConfig } from "@/refresh-components/table/hooks/useColumnWidths";
import useDataTable, { toOnyxSortDirection } from "./hooks/useDataTable";
import useColumnWidths from "./hooks/useColumnWidths";
import useDraggableRows from "./hooks/useDraggableRows";
import TableElement from "./TableElement";
import TableHeader from "./TableHeader";
import TableBody from "./TableBody";
import TableRow from "./TableRow";
import TableHead from "./TableHead";
import TableCell from "./TableCell";
import TableQualifier from "./TableQualifier";
import QualifierContainer from "./QualifierContainer";
import ActionsContainer from "./ActionsContainer";
import DragOverlayRow from "./DragOverlayRow";
import Footer from "./Footer";
import { TableSizeProvider } from "./TableSizeContext";
import { ColumnVisibilityPopover } from "./ColumnVisibilityPopover";
import { SortingPopover } from "./SortingPopover";
import type { WidthConfig } from "./hooks/useColumnWidths";
import type { ColumnDef } from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import { cn } from "@opal/utils";
import type {
DataTableProps,
DataTableFooterConfig,
@@ -32,8 +32,8 @@ import type {
OnyxDataColumn,
OnyxQualifierColumn,
OnyxActionsColumn,
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
} from "./types";
import type { TableSize } from "./TableSizeContext";
// ---------------------------------------------------------------------------
// Internal: resolve size-dependent widths and build TanStack columns
@@ -116,7 +116,7 @@ function processColumns<TData>(
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
* ```
*/
export default function DataTable<TData>(props: DataTableProps<TData>) {
export function Table<TData>(props: DataTableProps<TData>) {
const {
data,
columns,
@@ -126,7 +126,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
initialColumnVisibility,
draggable,
footer,
size = "regular",
size = "lg",
onSelectionChange,
onRowClick,
searchTerm,
@@ -303,7 +303,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
: undefined),
}}
>
<Table
<TableElement
width={
Object.keys(columnWidths).length > 0
? Object.values(columnWidths).reduce((sum, w) => sum + w, 0)
@@ -539,7 +539,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
);
})}
</TableBody>
</Table>
</TableElement>
</div>
{footer && renderFooter(footer)}

View File

@@ -5,17 +5,17 @@
/* ---- TableCell ---- */
.tbl-cell[data-size="regular"] {
.tbl-cell[data-size="lg"] {
@apply px-1 py-0.5;
}
.tbl-cell[data-size="small"] {
.tbl-cell[data-size="md"] {
@apply pl-0.5 pr-1.5 py-1.5;
}
.tbl-cell-inner[data-size="regular"] {
.tbl-cell-inner[data-size="lg"] {
@apply h-10 px-1;
}
.tbl-cell-inner[data-size="small"] {
.tbl-cell-inner[data-size="md"] {
@apply h-6 px-0.5;
}
@@ -25,10 +25,10 @@
@apply relative sticky top-0 z-20;
background: var(--table-header-bg, transparent);
}
.table-head[data-size="regular"] {
.table-head[data-size="lg"] {
@apply px-2 py-1;
}
.table-head[data-size="small"] {
.table-head[data-size="md"] {
@apply p-1.5;
}
.table-head[data-bottom-border] {
@@ -36,15 +36,15 @@
}
/* Inner text wrapper */
.table-head[data-size="regular"] .table-head-label {
.table-head[data-size="lg"] .table-head-label {
@apply py-2 px-0.5;
}
.table-head[data-size="small"] .table-head-label {
.table-head[data-size="md"] .table-head-label {
@apply py-1;
}
/* Sort button wrapper */
.table-head[data-size="regular"] .table-head-sort {
.table-head[data-size="lg"] .table-head-sort {
@apply py-1.5;
}
@@ -112,14 +112,14 @@
@apply w-px whitespace-nowrap py-1 sticky top-0 z-20;
background: var(--table-header-bg, transparent);
}
.tbl-qualifier[data-type="head"][data-size="small"] {
.tbl-qualifier[data-type="head"][data-size="md"] {
@apply py-0.5;
}
.tbl-qualifier[data-type="cell"] {
@apply w-px whitespace-nowrap py-1 pl-1;
}
.tbl-qualifier[data-type="cell"][data-size="small"] {
.tbl-qualifier[data-type="cell"][data-size="md"] {
@apply py-0.5 pl-0.5;
}
@@ -135,9 +135,9 @@
/* ---- Footer ---- */
.table-footer[data-size="regular"] {
.table-footer[data-size="lg"] {
@apply min-h-[2.75rem];
}
.table-footer[data-size="small"] {
.table-footer[data-size="md"] {
@apply min-h-[2.25rem];
}

View File

@@ -4,9 +4,9 @@ import type {
SortingState,
VisibilityState,
} from "@tanstack/react-table";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "./TableSizeContext";
import type { IconFunctionComponent } from "@opal/types";
import type { SortDirection } from "@/refresh-components/table/TableHead";
import type { SortDirection } from "./TableHead";
// ---------------------------------------------------------------------------
// Column width (mirrors useColumnWidths types)
@@ -166,7 +166,7 @@ export interface DataTableProps<TData> {
draggable?: DataTableDraggableConfig;
/** Footer configuration. */
footer?: DataTableFooterConfig;
/** Table size variant. @default "regular" */
/** Table size variant. @default "lg" */
size?: TableSize;
/** Called whenever the set of selected row IDs changes. Receives IDs produced by `getRowId`. */
onSelectionChange?: (selectedIds: string[]) => void;

View File

@@ -2,7 +2,14 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@opal/*": ["./src/*"]
"@opal/*": ["./src/*"],
// TODO (@raunakab): Remove this once the table component migration is
// complete. The table internals still import app-layer modules (e.g.
// @/refresh-components/texts/Text, @/refresh-components/Popover) via the
// @/ alias. Without this entry the IDE cannot resolve those paths since
// opal's tsconfig only defines @opal/*. Once all @/ deps are replaced
// with opal-internal equivalents, this line should be deleted.
"@/*": ["../../src/*"]
}
},
"include": ["src/**/*"],

View File

@@ -21,13 +21,10 @@ export const submitGoogleSite = async (
formData.append("files", file);
});
const response = await fetch(
"/api/manage/admin/connector/file/upload?unzip=false",
{
method: "POST",
body: formData,
}
);
const response = await fetch("/api/manage/admin/connector/file/upload", {
method: "POST",
body: formData,
});
const responseJson = await response.json();
if (!response.ok) {
toast.error(`Unable to upload files - ${responseJson.detail}`);

View File

@@ -16,7 +16,6 @@
@import "css/sizes.css";
@import "css/square-button.css";
@import "css/switch.css";
@import "css/table.css";
@import "css/z-index.css";
/* KH Teka Font */

View File

@@ -1,462 +0,0 @@
# DataTable
Config-driven table built on [TanStack Table](https://tanstack.com/table). Handles column sizing (weight-based proportional distribution), drag-and-drop row reordering, pagination, row selection, column visibility, and sorting out of the box.
## Quick Start
```tsx
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
interface Person {
name: string;
email: string;
role: string;
}
// Define columns at module scope (stable reference, no re-renders)
const tc = createTableColumns<Person>();
const columns = [
tc.qualifier(),
tc.column("name", { header: "Name", weight: 30, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 40, minWidth: 150 }),
tc.column("role", { header: "Role", weight: 30, minWidth: 80 }),
tc.actions(),
];
function PeopleTable({ data }: { data: Person[] }) {
return (
<DataTable
data={data}
columns={columns}
pageSize={10}
footer={{ mode: "selection" }}
/>
);
}
```
## Column Builder API
`createTableColumns<TData>()` returns a typed builder with four methods. Each returns an `OnyxColumnDef<TData>` that you pass to the `columns` prop.
### `tc.qualifier(config?)`
Leading column for avatars, icons, images, or checkboxes.
| Option | Type | Default | Description |
| ------------------- | ----------------------------------------------------------------- | ---------- | --------------------------------------------- |
| `content` | `"simple" \| "icon" \| "image" \| "avatar-icon" \| "avatar-user"` | `"simple"` | Body row content type |
| `headerContentType` | same as `content` | `"simple"` | Header row content type |
| `getInitials` | `(row: TData) => string` | - | Extract initials (for `"avatar-user"`) |
| `getIcon` | `(row: TData) => IconFunctionComponent` | - | Extract icon (for `"icon"` / `"avatar-icon"`) |
| `getImageSrc` | `(row: TData) => string` | - | Extract image src (for `"image"`) |
| `selectable` | `boolean` | `true` | Show selection checkboxes |
| `header` | `boolean` | `true` | Render qualifier content in the header |
Width is fixed: 56px at `"regular"` size, 40px at `"small"`.
```ts
tc.qualifier({
content: "avatar-user",
getInitials: (row) => row.initials,
});
```
### `tc.column(accessor, config)`
Data column with sorting, resizing, and hiding. The `accessor` is a type-safe deep key into `TData`.
| Option | Type | Default | Description |
| ---------------- | -------------------------------------------------- | ----------------------- | -------------------------------- |
| `header` | `string` | **required** | Column header label |
| `cell` | `(value: TValue, row: TData) => ReactNode` | renders value as string | Custom cell renderer |
| `enableSorting` | `boolean` | `true` | Allow sorting |
| `enableResizing` | `boolean` | `true` | Allow column resize |
| `enableHiding` | `boolean` | `true` | Allow hiding via actions popover |
| `icon` | `(sorted: SortDirection) => IconFunctionComponent` | - | Override the sort indicator icon |
| `weight` | `number` | `20` | Proportional width weight |
| `minWidth` | `number` | `50` | Minimum width in pixels |
```ts
tc.column("email", {
header: "Email",
weight: 28,
minWidth: 150,
cell: (value) => <Content sizePreset="main-ui" variant="body" title={value} prominence="muted" />,
})
```
### `tc.displayColumn(config)`
Non-accessor column for custom content (e.g. computed values, action buttons per row).
| Option | Type | Default | Description |
| -------------- | --------------------------- | ------------ | -------------------------------------- |
| `id` | `string` | **required** | Unique column ID |
| `header` | `string` | - | Optional header label |
| `cell` | `(row: TData) => ReactNode` | **required** | Cell renderer |
| `width` | `ColumnWidth` | **required** | `{ weight, minWidth? }` or `{ fixed }` |
| `enableHiding` | `boolean` | `true` | Allow hiding |
```ts
tc.displayColumn({
id: "fullName",
header: "Full Name",
cell: (row) => `${row.firstName} ${row.lastName}`,
width: { weight: 25, minWidth: 100 },
});
```
### `tc.actions(config?)`
Fixed-width column rendered at the trailing edge. Houses column visibility and sorting popovers in the header.
| Option | Type | Default | Description |
| ---------------------- | --------------------------- | ------- | ------------------------------------------ |
| `showColumnVisibility` | `boolean` | `true` | Show the column visibility popover |
| `showSorting` | `boolean` | `true` | Show the sorting popover |
| `sortingFooterText` | `string` | - | Footer text inside the sorting popover |
| `cell` | `(row: TData) => ReactNode` | - | Row-level cell renderer for action buttons |
Width is fixed: 88px at `"regular"`, 20px at `"small"`.
```ts
tc.actions({
sortingFooterText: "Everyone will see agents in this order.",
});
```
Row-level actions — the `cell` callback receives the row data and renders content in each body row. Clicks inside the cell automatically call `stopPropagation`, so they won't trigger row selection.
```tsx
tc.actions({
cell: (row) => (
<div className="flex gap-x-1">
<IconButton icon={SvgPencil} onClick={() => openEdit(row.id)} />
<IconButton icon={SvgTrash} onClick={() => confirmDelete(row.id)} />
</div>
),
});
```
## DataTable Props
`DataTableProps<TData>`:
| Prop | Type | Default | Description |
| ------------------------- | --------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `data` | `TData[]` | **required** | Row data |
| `columns` | `OnyxColumnDef<TData>[]` | **required** | Columns from `createTableColumns()` |
| `pageSize` | `number` | `10` (with footer) or `data.length` (without) | Rows per page. `Infinity` disables pagination |
| `initialSorting` | `SortingState` | `[]` | TanStack sorting state |
| `initialColumnVisibility` | `VisibilityState` | `{}` | Map of column ID to `false` to hide initially |
| `draggable` | `DataTableDraggableConfig<TData>` | - | Enable drag-and-drop (see below) |
| `footer` | `DataTableFooterConfig` | - | Footer mode (see below) |
| `size` | `"regular" \| "small"` | `"regular"` | Table density variant |
| `onRowClick` | `(row: TData) => void` | toggles selection | Called on row click, replaces default selection toggle |
| `height` | `number \| string` | - | Max height for scrollable body (header stays pinned). `300` or `"50vh"` |
| `headerBackground` | `string` | - | CSS color for the sticky header (prevents content showing through) |
| `searchTerm` | `string` | - | Search term for client-side global text filtering (case-insensitive match across all accessor columns) |
| `serverSide` | `ServerSideConfig` | - | Enable server-side mode for manual pagination, sorting, and filtering ([see below](#server-side-mode)) |
## Footer Config
The `footer` prop accepts a discriminated union on `mode`.
### Selection mode
For tables with selectable rows. Shows a selection message + count pagination.
```ts
footer={{
mode: "selection",
multiSelect: true, // default true
onView: () => { ... }, // optional "View" button
onClear: () => { ... }, // optional "Clear" button (falls back to default clearSelection)
}}
```
### Summary mode
For read-only tables. Shows "Showing X~Y of Z" + list pagination.
```ts
footer={{ mode: "summary" }}
```
## Draggable Config
Enable drag-and-drop row reordering. DnD is automatically disabled when column sorting is active.
```ts
<DataTable
data={items}
columns={columns}
draggable={{
getRowId: (row) => row.id,
onReorder: (ids, changedOrders) => {
// ids: new ordered array of all row IDs
// changedOrders: { [id]: newIndex } for rows that moved
setItems(ids.map((id) => items.find((r) => r.id === id)!));
},
}}
/>
```
| Option | Type | Description |
| ----------- | --------------------------------------------------------------------------------- | ---------------------------------------- |
| `getRowId` | `(row: TData) => string` | Extract a unique string ID from each row |
| `onReorder` | `(ids: string[], changedOrders: Record<string, number>) => void \| Promise<void>` | Called after a successful reorder |
## Server-Side Mode
Pass the `serverSide` prop to switch from client-side to server-side pagination, sorting, and filtering. In this mode `data` should contain **only the current page slice** — TanStack operates with `manualPagination`, `manualSorting`, and `manualFiltering` enabled. Drag-and-drop is automatically disabled.
### `ServerSideConfig`
| Prop | Type | Description |
| -------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------- |
| `totalItems` | `number` | Total row count from the server, used to compute page count |
| `isLoading` | `boolean` | Shows a loading overlay (opacity + pointer-events-none) while data is being fetched |
| `onSortingChange` | `(sorting: SortingState) => void` | Fired when the user clicks a column header |
| `onPaginationChange` | `(pageIndex: number, pageSize: number) => void` | Fired on page navigation and on automatic resets from sort/search changes |
| `onSearchTermChange` | `(searchTerm: string) => void` | Fired when the `searchTerm` prop changes |
### Callback contract
The callbacks fire in a predictable order:
- **Sort change** — `onSortingChange` fires first, then the page resets to 0 and `onPaginationChange(0, pageSize)` fires.
- **Page navigation** — only `onPaginationChange` fires.
- **Search change** — `onSearchTermChange` fires, and the page resets to 0. `onPaginationChange` only fires if the page was actually on a non-zero page. When already on page 0, `searchTerm` drives the re-fetch independently (e.g. via your SWR key) — no `onPaginationChange` is needed.
Your data-fetching layer should include `searchTerm` in its fetch dependencies (e.g. SWR key) so that search changes trigger re-fetches regardless of pagination state.
### Full example
```tsx
import { useState } from "react";
import useSWR from "swr";
import type { SortingState } from "@tanstack/react-table";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
interface User {
id: string;
name: string;
email: string;
}
const tc = createTableColumns<User>();
const columns = [
tc.qualifier(),
tc.column("name", { header: "Name", weight: 40, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 60, minWidth: 150 }),
tc.actions(),
];
function UsersTable() {
const [searchTerm, setSearchTerm] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const { data: response, isLoading } = useSWR(
["/api/users", sorting, pageIndex, pageSize, searchTerm],
([url, sorting, pageIndex, pageSize, searchTerm]) =>
fetch(
`${url}?` +
new URLSearchParams({
page: String(pageIndex),
size: String(pageSize),
search: searchTerm,
...(sorting[0] && {
sortBy: sorting[0].id,
sortDir: sorting[0].desc ? "desc" : "asc",
}),
})
).then((r) => r.json())
);
return (
<div className="space-y-4">
<InputTypeIn
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search users..."
/>
<DataTable
data={response?.items ?? []}
columns={columns}
getRowId={(row) => row.id}
searchTerm={searchTerm}
pageSize={pageSize}
footer={{ mode: "summary" }}
serverSide={{
totalItems: response?.total ?? 0,
isLoading,
onSortingChange: setSorting,
onPaginationChange: (idx, size) => {
setPageIndex(idx);
setPageSize(size);
},
onSearchTermChange: () => {
// search state is already managed above via searchTerm prop;
// this callback is useful for analytics or debouncing
},
}}
/>
</div>
);
}
```
## Sizing
The `size` prop (`"regular"` or `"small"`) affects:
- Qualifier column width (56px vs 40px)
- Actions column width (88px vs 20px)
- Footer text styles and pagination size
- All child components via `TableSizeContext`
Column widths can be responsive to size using a function:
```ts
// In types.ts, width accepts:
width: ColumnWidth | ((size: TableSize) => ColumnWidth);
// Example (this is what qualifier/actions use internally):
width: (size) => (size === "small" ? { fixed: 40 } : { fixed: 56 });
```
### Width system
Data columns use **weight-based proportional distribution**. A column with `weight: 40` gets twice the space of one with `weight: 20`. When the container is narrower than the sum of `minWidth` values, columns clamp to their minimums.
Fixed columns (`{ fixed: N }`) take exactly N pixels and don't participate in proportional distribution.
Resizing uses **splitter semantics**: dragging a column border grows that column and shrinks its neighbor by the same amount, keeping total width constant.
## Advanced Examples
### Scrollable table with pinned header
```tsx
<DataTable
data={allRows}
columns={columns}
height={300}
headerBackground="var(--background-tint-00)"
/>
```
### Hidden columns on load
```tsx
<DataTable
data={data}
columns={columns}
initialColumnVisibility={{ department: false, joinDate: false }}
footer={{ mode: "selection" }}
/>
```
### Icon-based data column
```tsx
const STATUS_ICONS = {
active: SvgCheckCircle,
pending: SvgClock,
inactive: SvgAlertCircle,
} as const;
tc.column("status", {
header: "Status",
weight: 14,
minWidth: 80,
cell: (value) => (
<Content
sizePreset="main-ui"
variant="body"
icon={STATUS_ICONS[value]}
title={value.charAt(0).toUpperCase() + value.slice(1)}
/>
),
});
```
### Non-selectable qualifier with icons
```ts
tc.qualifier({
content: "icon",
getIcon: (row) => row.icon,
selectable: false,
header: false,
});
```
### Small variant in a bordered container
```tsx
<div className="border border-border-01 rounded-lg overflow-hidden">
<DataTable
data={data}
columns={columns}
size="small"
pageSize={10}
footer={{ mode: "selection" }}
/>
</div>
```
### Server-side pagination
Minimal wiring for server-side mode — manage sorting/pagination state externally and pass the current page slice as `data`.
```tsx
<DataTable
data={currentPageRows}
columns={columns}
getRowId={(row) => row.id}
searchTerm={searchTerm}
pageSize={pageSize}
footer={{ mode: "summary" }}
serverSide={{
totalItems: totalCount,
isLoading,
onSortingChange: setSorting,
onPaginationChange: (idx, size) => {
setPageIndex(idx);
setPageSize(size);
},
onSearchTermChange: (term) => setSearchTerm(term),
}}
/>
```
### Custom row click handler
```tsx
<DataTable
data={data}
columns={columns}
onRowClick={(row) => router.push(`/users/${row.id}`)}
/>
```
## Source Files
| File | Purpose |
| --------------------------- | -------------------------------- |
| `DataTable.tsx` | Main component |
| `columns.ts` | `createTableColumns` builder |
| `types.ts` | All TypeScript interfaces |
| `hooks/useDataTable.ts` | TanStack table wrapper hook |
| `hooks/useColumnWidths.ts` | Weight-based width system |
| `hooks/useDraggableRows.ts` | DnD hook (`@dnd-kit`) |
| `Footer.tsx` | Selection / Summary footer modes |
| `TableSizeContext.tsx` | Size context provider |

View File

@@ -1,281 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import Table from "./Table";
import TableHeader from "./TableHeader";
import TableBody from "./TableBody";
import TableRow from "./TableRow";
import TableHead from "./TableHead";
import TableCell from "./TableCell";
import { TableSizeProvider } from "./TableSizeContext";
import Text from "@/refresh-components/texts/Text";
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof Table> = {
title: "refresh-components/table/Table",
component: Table,
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<TableSizeProvider size="regular">
<div style={{ maxWidth: 800, padding: 16 }}>
<Story />
</div>
</TableSizeProvider>
</TooltipPrimitive.Provider>
),
],
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof Table>;
// ---------------------------------------------------------------------------
// Sample data
// ---------------------------------------------------------------------------
const connectors = [
{
name: "Google Drive",
type: "Cloud Storage",
docs: 1_240,
status: "Active",
},
{ name: "Confluence", type: "Wiki", docs: 856, status: "Active" },
{ name: "Slack", type: "Messaging", docs: 3_102, status: "Syncing" },
{ name: "Notion", type: "Wiki", docs: 412, status: "Paused" },
{ name: "GitHub", type: "Code", docs: 2_890, status: "Active" },
];
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
/** All primitive table components composed together (Table, TableHeader, TableBody, TableRow, TableHead, TableCell). */
export const ComposedPrimitives: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120} alignment="right">
Documents
</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiMono text03>
{c.docs.toLocaleString()}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Table rows with the "table" variant (bottom border instead of rounded corners). */
export const TableVariantRows: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name} variant="table">
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Row with selected state highlighted. */
export const SelectedRows: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c, i) => (
<TableRow key={c.name} selected={i === 1 || i === 3}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Sortable table headers with sort indicators. */
export const SortableHeaders: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200} sorted="ascending" onSort={() => {}}>
Connector
</TableHead>
<TableHead width={150} sorted="none" onSort={() => {}}>
Type
</TableHead>
<TableHead width={120} sorted="descending" onSort={() => {}}>
Documents
</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiMono text03>
{c.docs.toLocaleString()}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Small size variant with denser spacing. */
export const SmallSize: Story = {
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<TableSizeProvider size="small">
<div style={{ maxWidth: 800, padding: 16 }}>
<Story />
</div>
</TableSizeProvider>
</TooltipPrimitive.Provider>
),
],
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name}>
<TableCell>
<Text secondaryBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text secondaryBody text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text secondaryBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Disabled rows styling. */
export const DisabledRows: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c, i) => (
<TableRow key={c.name} disabled={i === 2 || i === 4}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};

View File

@@ -1,26 +0,0 @@
import { cn } from "@/lib/utils";
import type { WithoutStyles } from "@/types";
interface TableProps
extends WithoutStyles<React.TableHTMLAttributes<HTMLTableElement>> {
ref?: React.Ref<HTMLTableElement>;
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
* When provided the table uses exactly this width instead of stretching
* to fill its container, which prevents `table-layout: fixed` from
* redistributing extra space across columns on resize. */
width?: number;
}
function Table({ ref, width, ...props }: TableProps) {
return (
<table
ref={ref}
className={cn("border-separate border-spacing-0", "min-w-full")}
style={{ tableLayout: "fixed", width: width ?? undefined }}
{...props}
/>
);
}
export default Table;
export type { TableProps };

View File

@@ -1,8 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import { Table, createTableColumns } from "@opal/components";
import { Content } from "@opal/layouts";
import { Button } from "@opal/components";
import { SvgDownload } from "@opal/icons";
@@ -216,7 +215,7 @@ export default function UsersTable({
roleCounts={roleCounts}
statusCounts={statusCounts}
/>
<DataTable
<Table
data={filteredUsers}
columns={columns}
getRowId={(row) => row.id ?? row.email}

View File

@@ -169,9 +169,7 @@ test.describe("Project Files visual regression", () => {
.first();
await expect(iconWrapper).toBeVisible();
const container = page.locator("[data-main-container]");
await expect(container).toBeVisible();
await expectElementScreenshot(container, {
await expectElementScreenshot(filesSection, {
name: "project-files-long-underscore-filename",
});