mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-18 14:12:45 +00:00
Compare commits
1 Commits
main
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7425c59e39 |
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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>(
|
||||
@@ -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>;
|
||||
@@ -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(
|
||||
@@ -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";
|
||||
82
web/lib/opal/src/components/table/README.md
Normal file
82
web/lib/opal/src/components/table/README.md
Normal 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
|
||||
@@ -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;
|
||||
148
web/lib/opal/src/components/table/Table.stories.tsx
Normal file
148
web/lib/opal/src/components/table/Table.stories.tsx
Normal 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" }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -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
|
||||
70
web/lib/opal/src/components/table/TableElement.tsx
Normal file
70
web/lib/opal/src/components/table/TableElement.tsx
Normal 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,
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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)}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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/**/*"],
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 |
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user