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>