diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts
index 2ae0b08348..3548df2ad1 100644
--- a/frontend/src/api/backend/models.ts
+++ b/frontend/src/api/backend/models.ts
@@ -95,6 +95,7 @@ export interface Certificate {
proxyHosts?: ProxyHost[];
deadHosts?: DeadHost[];
redirectionHosts?: RedirectionHost[];
+ streams?: Stream[];
}
export interface ProxyLocation {
diff --git a/frontend/src/pages/Access/Table.test.tsx b/frontend/src/pages/Access/Table.test.tsx
new file mode 100644
index 0000000000..1327935d6a
--- /dev/null
+++ b/frontend/src/pages/Access/Table.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { LocaleProvider } from "src/context";
+import { describe, expect, it, vi } from "vitest";
+import Table from "./Table";
+
+vi.mock("src/hooks", () => ({
+ useUser: () => ({
+ data: {
+ roles: ["admin"],
+ permissions: {
+ visibility: "all",
+ proxyHosts: "manage",
+ redirectionHosts: "manage",
+ deadHosts: "manage",
+ streams: "manage",
+ accessLists: "manage",
+ certificates: "manage",
+ },
+ },
+ isLoading: false,
+ }),
+}));
+
+describe("Access table sorting", () => {
+ it("sorts rows when the name header is clicked", () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("columnheader", { name: /name/i }));
+
+ const rows = screen.getAllByRole("row").slice(1);
+ expect(within(rows[0]).getByText("Alpha")).toBeTruthy();
+ expect(within(rows[1]).getByText("Zulu")).toBeTruthy();
+ });
+});
diff --git a/frontend/src/pages/Access/Table.tsx b/frontend/src/pages/Access/Table.tsx
index 433c077b34..70cdbea55d 100644
--- a/frontend/src/pages/Access/Table.tsx
+++ b/frontend/src/pages/Access/Table.tsx
@@ -1,6 +1,12 @@
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
-import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
-import { useMemo } from "react";
+import {
+ createColumnHelper,
+ getCoreRowModel,
+ getSortedRowModel,
+ type SortingState,
+ useReactTable,
+} from "@tanstack/react-table";
+import { useMemo, useState } from "react";
import type { AccessList } from "src/api/backend";
import { EmptyData, GravatarFormatter, HasPermission, ValueWithDateFormatter } from "src/components";
import { TableLayout } from "src/components/Table/TableLayout";
@@ -21,6 +27,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
() => [
columnHelper.accessor((row: any) => row.owner, {
id: "owner",
+ enableSorting: false,
cell: (info: any) => {
const value = info.getValue();
return ;
@@ -32,6 +39,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
columnHelper.accessor((row: any) => row, {
id: "name",
header: intl.formatMessage({ id: "column.name" }),
+ sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
cell: (info: any) => (
),
@@ -39,11 +47,13 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
columnHelper.accessor((row: any) => row.items, {
id: "items",
header: intl.formatMessage({ id: "column.authorization" }),
+ sortingFn: (a, b) => (a.original.items?.length ?? 0) - (b.original.items?.length ?? 0),
cell: (info: any) => ,
}),
columnHelper.accessor((row: any) => row.clients, {
id: "clients",
header: intl.formatMessage({ id: "column.access" }),
+ sortingFn: (a, b) => (a.original.clients?.length ?? 0) - (b.original.clients?.length ?? 0),
cell: (info: any) => ,
}),
columnHelper.accessor((row: any) => row.satisfyAny, {
@@ -114,15 +124,21 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
[columnHelper, onEdit, onDelete],
);
+ const [sorting, setSorting] = useState([]);
+
const tableInstance = useReactTable({
columns,
data,
+ state: { sorting },
+ onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
enableSortingRemoval: false,
+ sortDescFirst: false,
});
return (
diff --git a/frontend/src/pages/Certificates/Table.test.tsx b/frontend/src/pages/Certificates/Table.test.tsx
new file mode 100644
index 0000000000..267fc8ba02
--- /dev/null
+++ b/frontend/src/pages/Certificates/Table.test.tsx
@@ -0,0 +1,71 @@
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { LocaleProvider } from "src/context";
+import { describe, expect, it, vi } from "vitest";
+import Table from "./Table";
+
+vi.mock("src/hooks", () => ({
+ useUser: () => ({
+ data: {
+ roles: ["admin"],
+ permissions: {
+ visibility: "all",
+ proxyHosts: "manage",
+ redirectionHosts: "manage",
+ deadHosts: "manage",
+ streams: "manage",
+ accessLists: "manage",
+ certificates: "manage",
+ },
+ },
+ isLoading: false,
+ }),
+}));
+
+describe("Certificates table sorting", () => {
+ it("sorts rows when the name header is clicked", () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("columnheader", { name: /name/i }));
+
+ const rows = screen.getAllByRole("row").slice(1);
+ expect(within(rows[0]).getByText("Alpha Cert")).toBeTruthy();
+ expect(within(rows[1]).getByText("Zulu Cert")).toBeTruthy();
+ });
+});
diff --git a/frontend/src/pages/Certificates/Table.tsx b/frontend/src/pages/Certificates/Table.tsx
index 7eaeb77701..e14a8fe43b 100644
--- a/frontend/src/pages/Certificates/Table.tsx
+++ b/frontend/src/pages/Certificates/Table.tsx
@@ -1,6 +1,12 @@
import { IconDotsVertical, IconDownload, IconRefresh, IconTrash } from "@tabler/icons-react";
-import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
-import { useMemo } from "react";
+import {
+ createColumnHelper,
+ getCoreRowModel,
+ getSortedRowModel,
+ type SortingState,
+ useReactTable,
+} from "@tanstack/react-table";
+import { useMemo, useState } from "react";
import type { Certificate } from "src/api/backend";
import {
CertificateInUseFormatter,
@@ -29,6 +35,7 @@ export default function Table({ data, isFetching, onDelete, onRenew, onDownload,
() => [
columnHelper.accessor((row: any) => row.owner, {
id: "owner",
+ enableSorting: false,
cell: (info: any) => {
const value = info.getValue();
return ;
@@ -40,6 +47,11 @@ export default function Table({ data, isFetching, onDelete, onRenew, onDownload,
columnHelper.accessor((row: any) => row, {
id: "domainNames",
header: intl.formatMessage({ id: "column.name" }),
+ sortingFn: (a, b) => {
+ const aVal = a.original.niceName || a.original.domainNames?.[0] || "";
+ const bVal = b.original.niceName || b.original.domainNames?.[0] || "";
+ return aVal.localeCompare(bVal);
+ },
cell: (info: any) => {
const value = info.getValue();
return (
@@ -55,6 +67,7 @@ export default function Table({ data, isFetching, onDelete, onRenew, onDownload,
columnHelper.accessor((row: any) => row, {
id: "provider",
header: intl.formatMessage({ id: "column.provider" }),
+ sortingFn: (a, b) => (a.original.provider ?? "").localeCompare(b.original.provider ?? ""),
cell: (info: any) => {
const r = info.getValue();
if (r.provider === "letsencrypt") {
@@ -83,6 +96,19 @@ export default function Table({ data, isFetching, onDelete, onRenew, onDownload,
columnHelper.accessor((row: any) => row, {
id: "proxyHosts",
header: intl.formatMessage({ id: "column.status" }),
+ sortingFn: (a, b) => {
+ const aVal =
+ (a.original.proxyHosts?.length ?? 0) +
+ (a.original.redirectionHosts?.length ?? 0) +
+ (a.original.deadHosts?.length ?? 0) +
+ (a.original.streams?.length ?? 0);
+ const bVal =
+ (b.original.proxyHosts?.length ?? 0) +
+ (b.original.redirectionHosts?.length ?? 0) +
+ (b.original.deadHosts?.length ?? 0) +
+ (b.original.streams?.length ?? 0);
+ return aVal - bVal;
+ },
cell: (info: any) => {
const r = info.getValue();
return (
@@ -164,15 +190,21 @@ export default function Table({ data, isFetching, onDelete, onRenew, onDownload,
[columnHelper, onDelete, onRenew, onDownload],
);
+ const [sorting, setSorting] = useState([]);
+
const tableInstance = useReactTable({
columns,
data,
+ state: { sorting },
+ onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
enableSortingRemoval: false,
+ sortDescFirst: false,
});
const customAddBtn = (