Compare commits

..

3 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
acaprau
64d484039f chore(opensearch): Disable test_update_single_can_clear_user_projects_and_personas (#9434) 2026-03-18 00:40:29 +00:00
Jamison Lahman
0530095b71 fix(fe): replace users table buttons with LineItems (#9435) 2026-03-17 23:45:15 +00:00
33 changed files with 445 additions and 927 deletions

View File

@@ -297,6 +297,10 @@ def index_batch_params(
class TestDocumentIndexOld:
"""Tests the old DocumentIndex interface."""
# TODO(ENG-3864)(andrei): Re-enable this test.
@pytest.mark.xfail(
reason="Flaky test: Retrieved chunks vary non-deterministically before and after changing user projects and personas. Likely a timing issue with the index being updated."
)
def test_update_single_can_clear_user_projects_and_personas(
self,
document_indices: list[DocumentIndex],

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

@@ -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

@@ -4,7 +4,6 @@ import {
fetchEnterpriseSettingsSS,
fetchSettingsSS,
} from "@/components/settings/lib";
import { fetchSS } from "@/lib/utilsSS";
import {
CUSTOM_ANALYTICS_ENABLED,
GTM_ENABLED,
@@ -56,28 +55,11 @@ export async function generateMetadata(): Promise<Metadata> {
: "/onyx.ico";
}
const useCustomLogo =
enterpriseSettings && enterpriseSettings.use_custom_logo;
let logoMimeType: string = "image/png";
if (useCustomLogo) {
try {
const logoRes = await fetchSS("/enterprise-settings/logo", {
method: "HEAD",
});
logoMimeType = logoRes.headers.get("content-type") || "image/png";
} catch {
// Fall back to image/png if the HEAD request fails
}
}
return {
title: enterpriseSettings?.application_name || "Onyx",
description: "Question answering for your documents",
icons: {
icon: useCustomLogo
? { url: logoLocation, type: logoMimeType }
: { url: logoLocation, type: "image/x-icon" },
icon: logoLocation,
},
};
}

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

@@ -12,6 +12,7 @@ import {
SvgKey,
} from "@opal/icons";
import { Disabled } from "@opal/core";
import LineItem from "@/refresh-components/buttons/LineItem";
import Popover from "@/refresh-components/Popover";
import Separator from "@/refresh-components/Separator";
import { Section } from "@/layouts/general-layouts";
@@ -78,18 +79,17 @@ export default function UserRowActions({
return (
<>
{user.id && (
<Button
prominence="tertiary"
<LineItem
icon={SvgUsers}
onClick={() => openModal(Modal.EDIT_GROUPS)}
>
Groups &amp; Roles
</Button>
</LineItem>
)}
<Disabled disabled>
<Button prominence="tertiary" variant="danger" icon={SvgUserX}>
<LineItem danger icon={SvgUserX}>
Deactivate User
</Button>
</LineItem>
</Disabled>
<Separator paddingXRem={0.5} />
<Text as="p" secondaryBody text03 className="px-3 py-1">
@@ -102,20 +102,18 @@ export default function UserRowActions({
switch (user.status) {
case UserStatus.INVITED:
return (
<Button
prominence="tertiary"
variant="danger"
<LineItem
danger
icon={SvgXCircle}
onClick={() => openModal(Modal.CANCEL_INVITE)}
>
Cancel Invite
</Button>
</LineItem>
);
case UserStatus.REQUESTED:
return (
<Button
prominence="tertiary"
<LineItem
icon={SvgUserCheck}
onClick={() => {
setPopoverOpen(false);
@@ -133,37 +131,34 @@ export default function UserRowActions({
}}
>
Approve
</Button>
</LineItem>
);
case UserStatus.ACTIVE:
return (
<>
{user.id && (
<Button
prominence="tertiary"
<LineItem
icon={SvgUsers}
onClick={() => openModal(Modal.EDIT_GROUPS)}
>
Groups &amp; Roles
</Button>
</LineItem>
)}
<Button
prominence="tertiary"
<LineItem
icon={SvgKey}
onClick={() => openModal(Modal.RESET_PASSWORD)}
>
Reset Password
</Button>
</LineItem>
<Separator paddingXRem={0.5} />
<Button
prominence="tertiary"
variant="danger"
<LineItem
danger
icon={SvgUserX}
onClick={() => openModal(Modal.DEACTIVATE)}
>
Deactivate User
</Button>
</LineItem>
</>
);
@@ -171,38 +166,34 @@ export default function UserRowActions({
return (
<>
{user.id && (
<Button
prominence="tertiary"
<LineItem
icon={SvgUsers}
onClick={() => openModal(Modal.EDIT_GROUPS)}
>
Groups &amp; Roles
</Button>
</LineItem>
)}
<Button
prominence="tertiary"
<LineItem
icon={SvgKey}
onClick={() => openModal(Modal.RESET_PASSWORD)}
>
Reset Password
</Button>
</LineItem>
<Separator paddingXRem={0.5} />
<Button
prominence="tertiary"
<LineItem
icon={SvgUserPlus}
onClick={() => openModal(Modal.ACTIVATE)}
>
Activate User
</Button>
</LineItem>
<Separator paddingXRem={0.5} />
<Button
prominence="tertiary"
variant="danger"
<LineItem
danger
icon={SvgUserX}
onClick={() => openModal(Modal.DELETE)}
>
Delete User
</Button>
</LineItem>
</>
);

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}