Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/api/backend/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export interface Certificate {
proxyHosts?: ProxyHost[];
deadHosts?: DeadHost[];
redirectionHosts?: RedirectionHost[];
streams?: Stream[];
}

export interface ProxyLocation {
Expand Down
63 changes: 63 additions & 0 deletions frontend/src/pages/Access/Table.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<LocaleProvider>
<Table
data={[
{
id: 1,
ownerUserId: 1,
name: "Zulu",
meta: {},
satisfyAny: false,
passAuth: false,
proxyHostCount: 0,
items: [],
clients: [],
},
{
id: 2,
ownerUserId: 1,
name: "Alpha",
meta: {},
satisfyAny: true,
passAuth: false,
proxyHostCount: 0,
items: [],
clients: [],
},
]}
/>
</LocaleProvider>,
);

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();
});
});
20 changes: 18 additions & 2 deletions frontend/src/pages/Access/Table.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <GravatarFormatter url={value ? value.avatar : ""} name={value ? value.name : ""} />;
Expand All @@ -32,18 +39,21 @@ 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) => (
<ValueWithDateFormatter value={info.getValue().name} createdOn={info.getValue().createdOn} />
),
}),
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) => <T id="access-list.auth-count" data={{ count: info.getValue().length }} />,
}),
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) => <T id="access-list.access-count" data={{ count: info.getValue().length }} />,
}),
columnHelper.accessor((row: any) => row.satisfyAny, {
Expand Down Expand Up @@ -114,15 +124,21 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
[columnHelper, onEdit, onDelete],
);

const [sorting, setSorting] = useState<SortingState>([]);

const tableInstance = useReactTable<AccessList>({
columns,
data,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
enableSortingRemoval: false,
sortDescFirst: false,
});

return (
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/pages/Certificates/Table.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<LocaleProvider>
<Table
data={[
{
id: 1,
createdOn: "2026-01-01T00:00:00.000Z",
modifiedOn: "2026-01-01T00:00:00.000Z",
ownerUserId: 1,
provider: "other",
niceName: "Zulu Cert",
domainNames: ["zulu.example"],
expiresOn: "2027-01-01T00:00:00.000Z",
meta: {},
proxyHosts: [],
deadHosts: [],
redirectionHosts: [],
streams: [],
},
{
id: 2,
createdOn: "2026-01-01T00:00:00.000Z",
modifiedOn: "2026-01-01T00:00:00.000Z",
ownerUserId: 1,
provider: "other",
niceName: "Alpha Cert",
domainNames: ["alpha.example"],
expiresOn: "2027-01-01T00:00:00.000Z",
meta: {},
proxyHosts: [],
deadHosts: [],
redirectionHosts: [],
streams: [],
},
]}
/>
</LocaleProvider>,
);

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();
});
});
36 changes: 34 additions & 2 deletions frontend/src/pages/Certificates/Table.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 <GravatarFormatter url={value ? value.avatar : ""} name={value ? value.name : ""} />;
Expand All @@ -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 (
Expand All @@ -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") {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -164,15 +190,21 @@ export default function Table({ data, isFetching, onDelete, onRenew, onDownload,
[columnHelper, onDelete, onRenew, onDownload],
);

const [sorting, setSorting] = useState<SortingState>([]);

const tableInstance = useReactTable<Certificate>({
columns,
data,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
enableSortingRemoval: false,
sortDescFirst: false,
});

const customAddBtn = (
Expand Down