You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by ya...@apache.org on 2026/10/17 22:02:17 UTC

(airavata-custos) 01/01: update allocations table to show allocation names and grouped by projects

This is an automated email from the ASF dual-hosted git repository.

yasithdev pushed a commit to branch allocations-names
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit e08bd05f5b7f0d8a278ceab5be21278e8b369dde
Author: Yasith Jayawardana <ya...@icloud.com>
AuthorDate: Sat Oct 17 18:02:05 2026 -0400

    update allocations table to show allocation names and grouped by projects
---
 .../allocations/AllocationsListContainer.tsx       |  3 ++
 .../core/allocations/__fixtures__/allocations.json |  8 ++--
 .../allocations/__tests__/AllocationsList.test.tsx | 42 +++++++++++++++++++--
 .../components/AllocationChangeRequestsTab.tsx     |  4 ++
 .../components/AllocationHistoryTab.tsx            |  4 ++
 .../components/AllocationMembersTab.tsx            |  3 ++
 .../allocations/components/AllocationsList.tsx     | 44 +++++++++++-----------
 .../components/ChangeRequestApproverQueue.tsx      |  4 ++
 .../core/clusters/components/ClusterName.tsx       | 26 +++++++++++++
 .../projects/components/ProjectAllocationsTab.tsx  |  3 ++
 .../core/projects/components/ProjectMembersTab.tsx |  3 ++
 .../core/projects/components/ProjectName.tsx       | 26 +++++++++++++
 .../core/projects/components/ProjectsList.tsx      |  2 +
 web/src/shared/ui/DataTable.tsx                    | 29 ++++++++++++--
 14 files changed, 169 insertions(+), 32 deletions(-)

diff --git a/web/src/app/(portal)/allocations/AllocationsListContainer.tsx b/web/src/app/(portal)/allocations/AllocationsListContainer.tsx
index 3f384030e..0d9383723 100644
--- a/web/src/app/(portal)/allocations/AllocationsListContainer.tsx
+++ b/web/src/app/(portal)/allocations/AllocationsListContainer.tsx
@@ -21,6 +21,7 @@ import * as React from "react";
 import { AllocationsList } from "@/features/core/allocations/components/AllocationsList";
 import { useAllocations } from "@/features/core/allocations/queries";
 import type { AllocationStatus } from "@/features/core/allocations/schemas";
+import { useAbility } from "@/shared/casl/AbilityProvider";
 import {
   replaceShallowSearchParams,
   useShallowSearchParams,
@@ -32,6 +33,7 @@ function statusFilterFromUrl(raw: string | null): AllocationStatus | "all" {
 }
 
 export function AllocationsListContainer() {
+  const ability = useAbility();
   const searchParams = useShallowSearchParams();
   const search = searchParams.get("q") ?? "";
   const statusFilter = statusFilterFromUrl(searchParams.get("status"));
@@ -59,6 +61,7 @@ export function AllocationsListContainer() {
       onSearchChange={(next) => updateParam("q", next)}
       statusFilter={statusFilter}
       onStatusFilterChange={(next) => updateParam("status", next === "all" ? null : next)}
+      showCluster={ability.can("read", "Cluster")}
     />
   );
 }
diff --git a/web/src/features/core/allocations/__fixtures__/allocations.json b/web/src/features/core/allocations/__fixtures__/allocations.json
index d7e0c9f0c..88adfa4da 100644
--- a/web/src/features/core/allocations/__fixtures__/allocations.json
+++ b/web/src/features/core/allocations/__fixtures__/allocations.json
@@ -4,7 +4,7 @@
     "project_id": "project-001",
     "name": "Genomic Sequencing - GPU Pool",
     "status": "ACTIVE",
-    "compute_cluster_id": "cluster-001",
+    "compute_cluster_id": "cluster-a",
     "initial_su_amount": 250000,
     "start_time": "2026-04-01T00:00:00.000Z",
     "end_time": "2027-03-31T00:00:00.000Z",
@@ -15,7 +15,7 @@
     "project_id": "project-001",
     "name": "Genomic Sequencing - CPU Pool",
     "status": "ACTIVE",
-    "compute_cluster_id": "cluster-002",
+    "compute_cluster_id": "cluster-b",
     "initial_su_amount": 80000,
     "start_time": "2026-04-01T00:00:00.000Z",
     "end_time": "2027-03-31T00:00:00.000Z",
@@ -26,7 +26,7 @@
     "project_id": "project-002",
     "name": "Plasma Simulation - GPU",
     "status": "ACTIVE",
-    "compute_cluster_id": "cluster-001",
+    "compute_cluster_id": "cluster-a",
     "initial_su_amount": 150000,
     "start_time": "2026-04-12T00:00:00.000Z",
     "end_time": "2027-04-11T00:00:00.000Z",
@@ -37,7 +37,7 @@
     "project_id": "project-003",
     "name": "Catalyst Discovery - Legacy",
     "status": "INACTIVE",
-    "compute_cluster_id": "cluster-002",
+    "compute_cluster_id": "cluster-b",
     "initial_su_amount": 50000,
     "start_time": "2026-02-09T00:00:00.000Z",
     "end_time": "2026-08-09T00:00:00.000Z",
diff --git a/web/src/features/core/allocations/__tests__/AllocationsList.test.tsx b/web/src/features/core/allocations/__tests__/AllocationsList.test.tsx
index 4a8e36d1d..c3bcd2f70 100644
--- a/web/src/features/core/allocations/__tests__/AllocationsList.test.tsx
+++ b/web/src/features/core/allocations/__tests__/AllocationsList.test.tsx
@@ -15,8 +15,13 @@
 // specific language governing permissions and limitations
 // under the License.
 
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import { render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { http, HttpResponse } from "msw";
+import { setupServer } from "msw/node";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import clusters from "@/features/core/clusters/__fixtures__/clusters.json";
+import projects from "@/features/core/projects/__fixtures__/projects.json";
 import { AllocationsList } from "../components/AllocationsList";
 import type { ComputeAllocation } from "../schemas";
 
@@ -25,7 +30,7 @@ const allocation: ComputeAllocation = {
   project_id: "project-001",
   name: "Genomic GPU Pool",
   status: "ACTIVE",
-  compute_cluster_id: "cluster-001",
+  compute_cluster_id: "cluster-a",
   initial_su_amount: 250000,
   start_time: "2026-04-01T00:00:00.000Z",
   end_time: "2027-03-31T00:00:00.000Z",
@@ -41,11 +46,33 @@ function renderList(overrides: Partial<React.ComponentProps<typeof AllocationsLi
     onSearchChange: vi.fn(),
     statusFilter: "all" as const,
     onStatusFilterChange: vi.fn(),
+    showCluster: true,
     ...overrides,
   };
-  return { ...render(<AllocationsList {...props} />), props };
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return {
+    ...render(
+      <QueryClientProvider client={client}>
+        <AllocationsList {...props} />
+      </QueryClientProvider>,
+    ),
+    props,
+  };
 }
 
+const server = setupServer(
+  http.get("*/api/v1/projects/:id", ({ params }) =>
+    HttpResponse.json(projects.find((p) => p.id === params.id)),
+  ),
+  http.get("*/api/v1/compute-clusters/:id", ({ params }) =>
+    HttpResponse.json(clusters.find((c) => c.id === params.id)),
+  ),
+);
+
+beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
+afterAll(() => server.close());
+afterEach(() => server.resetHandlers());
+
 describe("<AllocationsList />", () => {
   it("renders the allocation name as a link to the detail page", () => {
     renderList();
@@ -53,6 +80,15 @@ describe("<AllocationsList />", () => {
     expect(link).toHaveAttribute("href", `/allocations/${allocation.id}`);
   });
 
+  it("groups rows under a project header and names the cluster", async () => {
+    renderList();
+    expect(await screen.findByRole("link", { name: "Genomic Sequencing Pipeline" })).toHaveAttribute(
+      "href",
+      `/projects/${allocation.project_id}`,
+    );
+    expect(await screen.findByText("ClusterA")).toBeInTheDocument();
+  });
+
   it("shows the empty state when no rows match the filters", () => {
     renderList({ rows: [] });
     expect(screen.getByRole("heading", { name: /no allocations yet/i })).toBeInTheDocument();
diff --git a/web/src/features/core/allocations/components/AllocationChangeRequestsTab.tsx b/web/src/features/core/allocations/components/AllocationChangeRequestsTab.tsx
index dbbd40177..6f0003184 100644
--- a/web/src/features/core/allocations/components/AllocationChangeRequestsTab.tsx
+++ b/web/src/features/core/allocations/components/AllocationChangeRequestsTab.tsx
@@ -68,6 +68,7 @@ export function AllocationChangeRequestsTab({
     {
       key: "submitted",
       header: "Submitted",
+      width: "9rem",
       cell: (row) => (
         <span className="text-sm text-muted-foreground">{formatDate(row.timestamp)}</span>
       ),
@@ -80,6 +81,7 @@ export function AllocationChangeRequestsTab({
     {
       key: "amount",
       header: "Requested SUs",
+      width: "10rem",
       align: "right",
       cell: (row) => (
         <span className="tabular-nums">{new Intl.NumberFormat().format(row.requested_su_amount)}</span>
@@ -88,6 +90,7 @@ export function AllocationChangeRequestsTab({
     {
       key: "status",
       header: "Status",
+      width: "8rem",
       cell: (row) => (
         <StatusBadge
           variant={statusBadgeVariantFromChangeRequest(row.change_status)}
@@ -98,6 +101,7 @@ export function AllocationChangeRequestsTab({
     {
       key: "actions",
       header: "",
+      width: "6rem",
       align: "right",
       interactive: true,
       cell: (row) => (
diff --git a/web/src/features/core/allocations/components/AllocationHistoryTab.tsx b/web/src/features/core/allocations/components/AllocationHistoryTab.tsx
index f94b17012..cf516ed57 100644
--- a/web/src/features/core/allocations/components/AllocationHistoryTab.tsx
+++ b/web/src/features/core/allocations/components/AllocationHistoryTab.tsx
@@ -62,6 +62,7 @@ export function AllocationHistoryTab({ allocation }: AllocationHistoryTabProps)
     {
       key: "when",
       header: "When",
+      width: "12rem",
       cell: (row) => (
         <span className="text-sm text-muted-foreground">{formatDateTime(row.timestamp)}</span>
       ),
@@ -69,11 +70,13 @@ export function AllocationHistoryTab({ allocation }: AllocationHistoryTabProps)
     {
       key: "type",
       header: "Type",
+      width: "10rem",
       cell: (row) => <span className="text-sm">{row.diff_type}</span>,
     },
     {
       key: "su",
       header: "New SU amount",
+      width: "10rem",
       align: "right",
       cell: (row) => (
         <span className="tabular-nums">
@@ -86,6 +89,7 @@ export function AllocationHistoryTab({ allocation }: AllocationHistoryTabProps)
     {
       key: "status",
       header: "Status",
+      width: "8rem",
       cell: (row) => (
         <StatusBadge
           variant={statusBadgeVariantFromAllocationStatus(row.status)}
diff --git a/web/src/features/core/allocations/components/AllocationMembersTab.tsx b/web/src/features/core/allocations/components/AllocationMembersTab.tsx
index 07a803405..2f71d108a 100644
--- a/web/src/features/core/allocations/components/AllocationMembersTab.tsx
+++ b/web/src/features/core/allocations/components/AllocationMembersTab.tsx
@@ -125,6 +125,7 @@ export function AllocationMembersTab({ allocation, canManage }: AllocationMember
     {
       key: "role",
       header: "Role",
+      width: "11rem",
       cell: (row) => (
         <span className="text-sm">{row.role ? ROLE_LABELS[row.role] : "—"}</span>
       ),
@@ -132,6 +133,7 @@ export function AllocationMembersTab({ allocation, canManage }: AllocationMember
     {
       key: "status",
       header: "Status",
+      width: "8rem",
       cell: (row) => (
         <StatusBadge
           variant={
@@ -148,6 +150,7 @@ export function AllocationMembersTab({ allocation, canManage }: AllocationMember
     {
       key: "actions",
       header: "",
+      width: "11rem",
       align: "right",
       interactive: true,
       // PI and CO_PI are upstream-owned; the portal never edits or removes them.
diff --git a/web/src/features/core/allocations/components/AllocationsList.tsx b/web/src/features/core/allocations/components/AllocationsList.tsx
index 3cecbcf29..0a08e086c 100644
--- a/web/src/features/core/allocations/components/AllocationsList.tsx
+++ b/web/src/features/core/allocations/components/AllocationsList.tsx
@@ -19,6 +19,8 @@
 
 import Link from "next/link";
 import * as React from "react";
+import { ClusterName } from "@/features/core/clusters/components/ClusterName";
+import { ProjectName } from "@/features/core/projects/components/ProjectName";
 import { DataTable, type DataTableColumn } from "@/shared/ui/DataTable";
 import { EmptyState } from "@/shared/ui/EmptyState";
 import { ErrorState } from "@/shared/ui/ErrorState";
@@ -69,6 +71,7 @@ export type AllocationsListProps = {
   onSearchChange: (next: string) => void;
   statusFilter: AllocationStatus | "all";
   onStatusFilterChange: (next: AllocationStatus | "all") => void;
+  showCluster: boolean;
 };
 
 export function AllocationsList({
@@ -80,6 +83,7 @@ export function AllocationsList({
   onSearchChange,
   statusFilter,
   onStatusFilterChange,
+  showCluster,
 }: AllocationsListProps) {
   const filtered = React.useMemo(() => {
     const needle = search.trim().toLowerCase();
@@ -99,7 +103,6 @@ export function AllocationsList({
       header: "Allocation",
       sortable: true,
       sortValue: (row) => row.name,
-      width: "14rem",
       cell: (row) => (
         <div className="flex flex-col gap-0.5">
           <Link
@@ -112,32 +115,15 @@ export function AllocationsList({
         </div>
       ),
     },
-    {
-      key: "project",
-      header: "Project",
-      sortable: true,
-      sortValue: (row) => row.project_id,
-      width: "12rem",
-      cell: (row) => (
-        <Link
-          href={`/projects/${row.project_id}`}
-          className="break-all text-sm text-muted-foreground hover:underline"
-        >
-          {row.project_id}
-        </Link>
-      ),
-    },
     {
       key: "cluster",
       header: "Cluster",
-      sortable: true,
-      sortValue: (row) => row.compute_cluster_id,
-      width: "12rem",
-      cell: (row) => <span className="break-all text-sm">{row.compute_cluster_id}</span>,
+      cell: (row) => <ClusterName id={row.compute_cluster_id} />,
     },
     {
       key: "initial",
       header: "Initial SUs",
+      width: "8rem",
       sortable: true,
       sortValue: (row) => row.initial_su_amount,
       cell: (row) => <span className="tabular-nums">{formatSU(row.initial_su_amount)}</span>,
@@ -145,6 +131,7 @@ export function AllocationsList({
     {
       key: "endDate",
       header: "End date",
+      width: "9rem",
       sortable: true,
       sortValue: (row) => new Date(row.end_time),
       cell: (row) => (
@@ -154,6 +141,7 @@ export function AllocationsList({
     {
       key: "status",
       header: "Status",
+      width: "8rem",
       sortable: true,
       sortValue: (row) => row.status,
       cell: (row) => (
@@ -200,7 +188,7 @@ export function AllocationsList({
       </div>
 
       {isLoading ? (
-        <TableSkeleton rows={6} columns={6} />
+        <TableSkeleton rows={6} columns={5} />
       ) : error ? (
         <ErrorState message={error.message} onRetry={onRetry} />
       ) : filtered.length === 0 ? (
@@ -209,7 +197,19 @@ export function AllocationsList({
           description="No allocations match the current filters."
         />
       ) : (
-        <DataTable columns={columns} rows={filtered} rowKey={(row) => row.id} />
+        <DataTable
+          columns={columns.filter((col) => showCluster || col.key !== "cluster")}
+          rows={filtered}
+          rowKey={(row) => row.id}
+          groupBy={{
+            key: (row) => row.project_id,
+            header: (row) => (
+              <Link href={`/projects/${row.project_id}`} className="hover:underline">
+                <ProjectName id={row.project_id} />
+              </Link>
+            ),
+          }}
+        />
       )}
     </div>
   );
diff --git a/web/src/features/core/allocations/components/ChangeRequestApproverQueue.tsx b/web/src/features/core/allocations/components/ChangeRequestApproverQueue.tsx
index aca615a30..9c5b9948b 100644
--- a/web/src/features/core/allocations/components/ChangeRequestApproverQueue.tsx
+++ b/web/src/features/core/allocations/components/ChangeRequestApproverQueue.tsx
@@ -124,6 +124,7 @@ export function ChangeRequestApproverQueue({
     {
       key: "submitted",
       header: "Submitted",
+      width: "9rem",
       sortable: true,
       sortValue: (row) => new Date(row.timestamp),
       cell: (row) => (
@@ -150,6 +151,7 @@ export function ChangeRequestApproverQueue({
     {
       key: "amount",
       header: "Requested SUs",
+      width: "10rem",
       align: "right",
       sortable: true,
       sortValue: (row) => row.requested_su_amount,
@@ -160,6 +162,7 @@ export function ChangeRequestApproverQueue({
     {
       key: "status",
       header: "Status",
+      width: "8rem",
       cell: (row) => (
         <StatusBadge
           variant={statusBadgeVariantFromChangeRequest(row.change_status)}
@@ -170,6 +173,7 @@ export function ChangeRequestApproverQueue({
     {
       key: "actions",
       header: "",
+      width: "11rem",
       align: "right",
       interactive: true,
       cell: (row) => {
diff --git a/web/src/features/core/clusters/components/ClusterName.tsx b/web/src/features/core/clusters/components/ClusterName.tsx
new file mode 100644
index 000000000..fe9d2effd
--- /dev/null
+++ b/web/src/features/core/clusters/components/ClusterName.tsx
@@ -0,0 +1,26 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import { useCluster } from "../queries";
+
+// A cluster's name for records that carry only its id, which is never shown in its place.
+export function ClusterName({ id }: { id: string }) {
+  const cluster = useCluster(id);
+  return <>{cluster.data?.name || (cluster.isLoading ? "—" : "Unknown cluster")}</>;
+}
diff --git a/web/src/features/core/projects/components/ProjectAllocationsTab.tsx b/web/src/features/core/projects/components/ProjectAllocationsTab.tsx
index 9e9265ad1..e976ffd7f 100644
--- a/web/src/features/core/projects/components/ProjectAllocationsTab.tsx
+++ b/web/src/features/core/projects/components/ProjectAllocationsTab.tsx
@@ -84,6 +84,7 @@ export function ProjectAllocationsTab({ projectId }: ProjectAllocationsTabProps)
     {
       key: "initial",
       header: "Initial SUs",
+      width: "8rem",
       align: "right",
       cell: (row) => (
         <span className="tabular-nums">{new Intl.NumberFormat().format(row.initial_su_amount)}</span>
@@ -92,6 +93,7 @@ export function ProjectAllocationsTab({ projectId }: ProjectAllocationsTabProps)
     {
       key: "endDate",
       header: "End date",
+      width: "9rem",
       cell: (row) => (
         <span className="text-sm text-muted-foreground">{formatDate(row.end_time)}</span>
       ),
@@ -99,6 +101,7 @@ export function ProjectAllocationsTab({ projectId }: ProjectAllocationsTabProps)
     {
       key: "status",
       header: "Status",
+      width: "8rem",
       cell: (row) => (
         <StatusBadge
           variant={statusBadgeVariantFromAllocationStatus(row.status)}
diff --git a/web/src/features/core/projects/components/ProjectMembersTab.tsx b/web/src/features/core/projects/components/ProjectMembersTab.tsx
index 493dfae94..067beac29 100644
--- a/web/src/features/core/projects/components/ProjectMembersTab.tsx
+++ b/web/src/features/core/projects/components/ProjectMembersTab.tsx
@@ -140,6 +140,7 @@ export function ProjectMembersTab({ projectId, canManage }: ProjectMembersTabPro
     {
       key: "role",
       header: "Role",
+      width: "11rem",
       cell: (row) => <span className="text-sm">{ROLE_LABELS[row.role]}</span>,
     },
     {
@@ -151,6 +152,7 @@ export function ProjectMembersTab({ projectId, canManage }: ProjectMembersTabPro
     {
       key: "status",
       header: "Status",
+      width: "8rem",
       cell: (row) => (
         <StatusBadge
           variant={row.status === "ACTIVE" ? "active" : row.status === "INACTIVE" ? "inactive" : "deleted"}
@@ -161,6 +163,7 @@ export function ProjectMembersTab({ projectId, canManage }: ProjectMembersTabPro
     {
       key: "actions",
       header: "",
+      width: "11rem",
       align: "right",
       interactive: true,
       // PI and CO_PI come from the upstream allocation; never edited or removed here.
diff --git a/web/src/features/core/projects/components/ProjectName.tsx b/web/src/features/core/projects/components/ProjectName.tsx
new file mode 100644
index 000000000..09a0fb92b
--- /dev/null
+++ b/web/src/features/core/projects/components/ProjectName.tsx
@@ -0,0 +1,26 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+"use client";
+
+import { useProject } from "../queries";
+
+// A project's title for records that carry only its id, which is never shown in its place.
+export function ProjectName({ id }: { id: string }) {
+  const project = useProject(id);
+  return <>{project.data?.title || (project.isLoading ? "—" : "Unknown project")}</>;
+}
diff --git a/web/src/features/core/projects/components/ProjectsList.tsx b/web/src/features/core/projects/components/ProjectsList.tsx
index 8b53222fa..1ecc3a505 100644
--- a/web/src/features/core/projects/components/ProjectsList.tsx
+++ b/web/src/features/core/projects/components/ProjectsList.tsx
@@ -112,6 +112,7 @@ export function ProjectsList({
     {
       key: "origination",
       header: "Origination",
+      width: "8rem",
       sortable: true,
       sortValue: (row) => row.origination,
       cell: (row) => <span className="text-sm">{row.origination}</span>,
@@ -119,6 +120,7 @@ export function ProjectsList({
     {
       key: "status",
       header: "Status",
+      width: "8rem",
       sortable: true,
       sortValue: (row) => row.status,
       cell: (row) => (
diff --git a/web/src/shared/ui/DataTable.tsx b/web/src/shared/ui/DataTable.tsx
index 475074258..1cb53d673 100644
--- a/web/src/shared/ui/DataTable.tsx
+++ b/web/src/shared/ui/DataTable.tsx
@@ -64,6 +64,8 @@ export type DataTableProps<T> = {
   empty?: React.ReactNode;
   pagination?: DataTablePagination;
   className?: string;
+  // Groups rows under a full-width header row, in order of first appearance; sorting applies within each group.
+  groupBy?: { key: (row: T) => string; header: (row: T) => React.ReactNode };
 };
 
 function compareSortable(
@@ -97,6 +99,7 @@ export function DataTable<T>({
   empty,
   pagination,
   className,
+  groupBy,
 }: DataTableProps<T>) {
   const [sortState, setSortState] = React.useState<SortState>(null);
 
@@ -115,6 +118,25 @@ export function DataTable<T>({
       .map((entry) => entry.row);
   }, [rows, sortState, columns]);
 
+  const displayRows = groupBy
+    ? [...Map.groupBy(sortedRows, groupBy.key).values()].flat()
+    : sortedRows;
+
+  const groupHeader = (row: T, prev: T | undefined) => {
+    if (!groupBy || (prev !== undefined && groupBy.key(prev) === groupBy.key(row))) return null;
+    return (
+      <tr key={`group:${groupBy.key(row)}`} className="border-t border-border/60 bg-muted/40">
+        <th
+          scope="colgroup"
+          colSpan={columns.length}
+          className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"
+        >
+          {groupBy.header(row)}
+        </th>
+      </tr>
+    );
+  };
+
   const cycleSort = (key: string) => {
     setSortState((prev) => {
       if (!prev || prev.key !== key) return { key, direction: "asc" };
@@ -186,7 +208,7 @@ export function DataTable<T>({
             </tr>
           </thead>
           <tbody>
-            {sortedRows.length === 0 ? (
+            {displayRows.length === 0 ? (
               <tr>
                 <td colSpan={columns.length} className="px-4 py-12 text-center">
                   {empty ?? (
@@ -197,7 +219,8 @@ export function DataTable<T>({
                 </td>
               </tr>
             ) : (
-              sortedRows.map((row, index) => (
+              displayRows.map((row, index) => [
+                groupHeader(row, displayRows[index - 1]),
                 <tr
                   key={rowKey(row, index)}
                   className={cn(
@@ -277,7 +300,7 @@ export function DataTable<T>({
                     });
                   })()}
                 </tr>
-              ))
+              ])
             )}
           </tbody>
         </table>