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
52 changes: 52 additions & 0 deletions frontend/src/components/FolderField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useQueryClient } from "@tanstack/react-query";
import { useField } from "formik";
import { useMemo } from "react";
import type { ActionMeta, SingleValue } from "react-select";
import CreatableSelect from "react-select/creatable";
import { intl, T } from "src/locale";

type FolderOption = {
label: string;
value: string;
};

interface Props {
queryKey: string;
metaPath?: string;
}

export function FolderField({ queryKey, metaPath = "meta.folder" }: Props) {
const queryClient = useQueryClient();
const [field, , helpers] = useField(metaPath);

const options: FolderOption[] = useMemo(() => {
const allData = queryClient.getQueriesData<any[]>({ queryKey: [queryKey] }).flatMap(([, data]) => data ?? []);
return [...new Set(allData.map((h: any) => h.meta?.folder).filter(Boolean) as string[])]
.sort()
.map((f) => ({ label: f, value: f }));
}, [queryClient, queryKey]);

const currentValue: FolderOption | null = field.value ? { label: field.value, value: field.value } : null;

const handleChange = (newValue: SingleValue<FolderOption>, _actionMeta: ActionMeta<FolderOption>) => {
helpers.setValue(newValue?.value || undefined);
};

return (
<div className="mb-3">
<label htmlFor="folder-select" className="form-label">
<T id="folder" />
</label>
<CreatableSelect
inputId="folder-select"
className="react-select-container"
classNamePrefix="react-select"
isClearable
options={options}
value={currentValue}
onChange={handleChange}
placeholder={intl.formatMessage({ id: "folder.placeholder" })}
/>
</div>
);
}
9 changes: 7 additions & 2 deletions frontend/src/components/Table/TableBody.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { flexRender } from "@tanstack/react-table";
import { cloneElement, isValidElement } from "react";
import type { TableLayoutProps } from "src/components";
import { EmptyRow } from "./EmptyRow";

function TableBody<T>(props: TableLayoutProps<T>) {
const { tableInstance, extraStyles, emptyState } = props;
const { tableInstance, extraStyles, emptyState, renderRow } = props;
const rows = tableInstance.getRowModel().rows;

if (rows.length === 0) {
Expand All @@ -16,7 +17,11 @@ function TableBody<T>(props: TableLayoutProps<T>) {

return (
<tbody className="table-tbody">
{rows.map((row: any) => {
{rows.map((row) => {
if (renderRow) {
const node = renderRow(row);
return isValidElement(node) ? cloneElement(node, { key: row.id } as any) : node;
}
return (
<tr key={row.id} {...extraStyles?.row(row.original)}>
{row.getVisibleCells().map((cell: any) => {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/Table/TableLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import type { Table as ReactTable, Row } from "@tanstack/react-table";
import type { ReactNode } from "react";
import { TableBody } from "./TableBody";
import { TableHeader } from "./TableHeader";

Expand All @@ -8,6 +9,7 @@ interface TableLayoutProps<TFields> {
extraStyles?: {
row: (rowData: TFields) => any | undefined;
};
renderRow?: (row: Row<TFields>) => ReactNode;
}
function TableLayout<TFields>(props: TableLayoutProps<TFields>) {
const hasRows = props.tableInstance.getRowModel().rows.length > 0;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./Button";
export * from "./EmptyData";
export * from "./ErrorNotFound";
export * from "./Flag";
export * from "./FolderField";
export * from "./Form";
export * from "./HasPermission";
export * from "./Loading";
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/locale/src/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,12 @@
"expires.on": {
"defaultMessage": "Expires: {date}"
},
"folder": {
"defaultMessage": "Folder"
},
"folder.placeholder": {
"defaultMessage": "e.g. Production (optional)"
},
"footer.github-fork": {
"defaultMessage": "Fork me on Github"
},
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/modals/DeadHostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Modal from "react-bootstrap/Modal";
import {
Button,
DomainNamesField,
FolderField,
Loading,
NginxConfigField,
SSLCertificateField,
Expand Down Expand Up @@ -132,6 +133,7 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
<div className="tab-content">
<div className="tab-pane active show" id="tab-details" role="tabpanel">
<DomainNamesField isWildcardPermitted dnsProviderWildcardSupported />
<FolderField queryKey="dead-hosts" />
</div>
<div className="tab-pane" id="tab-ssl" role="tabpanel">
<SSLCertificateField
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/modals/ProxyHostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
AccessField,
Button,
DomainNamesField,
FolderField,
HasPermission,
Loading,
LocationsFields,
Expand Down Expand Up @@ -254,6 +255,7 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
</div>
</div>
<AccessField />
<FolderField queryKey="proxy-hosts" />
<div className="my-3">
<h4 className="py-2">
<T id="options" />
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/modals/RedirectionHostModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Modal from "react-bootstrap/Modal";
import {
Button,
DomainNamesField,
FolderField,
Loading,
NginxConfigField,
SSLCertificateField,
Expand Down Expand Up @@ -162,7 +163,9 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
required
{...field}
>
<option value="auto"><T id="auto" /></option>
<option value="auto">
<T id="auto" />
</option>
<option value="http">http</option>
<option value="https">https</option>
</select>
Expand Down Expand Up @@ -242,6 +245,7 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
</div>
)}
</Field>
<FolderField queryKey="redirection-hosts" />
<div className="my-3">
<h4 className="py-2">
<T id="options" />
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/modals/StreamModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Field, Form, Formik } from "formik";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { Button, Loading, SSLCertificateField, SSLOptionsFields } from "src/components";
import { Button, FolderField, Loading, SSLCertificateField, SSLOptionsFields } from "src/components";
import { useSetStream, useStream } from "src/hooks";
import { intl, T } from "src/locale";
import { validateNumber, validateString } from "src/modules/Validations";
Expand Down Expand Up @@ -280,6 +280,7 @@ const StreamModal = EasyModal.create(({ id, visible, remove }: Props) => {
</div>
</div>
</div>
<FolderField queryKey="streams" />
</div>
<div className="tab-pane" id="tab-ssl" role="tabpanel">
<SSLCertificateField
Expand Down
115 changes: 112 additions & 3 deletions frontend/src/pages/Nginx/DeadHosts/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import {
IconChevronDown,
IconChevronRight,
IconDotsVertical,
IconEdit,
IconPower,
IconTrash,
} from "@tabler/icons-react";
import {
createColumnHelper,
type ExpandedState,
flexRender,
type GroupingState,
getCoreRowModel,
getExpandedRowModel,
getGroupedRowModel,
type Row,
useReactTable,
} from "@tanstack/react-table";
import type { ReactNode } from "react";
import { useMemo } from "react";
import type { DeadHost } from "src/api/backend";
import {
Expand All @@ -18,15 +36,38 @@ interface Props {
data: DeadHost[];
isFiltered?: boolean;
isFetching?: boolean;
expanded: ExpandedState;
onExpandedChange: (expanded: ExpandedState) => void;
onEdit?: (id: number) => void;
onDelete?: (id: number) => void;
onDisableToggle?: (id: number, enabled: boolean) => void;
onNew?: () => void;
}
export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) {
export default function Table({
data,
isFetching,
expanded,
onExpandedChange,
onEdit,
onDelete,
onDisableToggle,
onNew,
isFiltered,
}: Props) {
const columnHelper = createColumnHelper<DeadHost>();

const grouping: GroupingState = useMemo(() => ["folder"], []);

const columns = useMemo(
() => [
// Hidden grouping column — drives TanStack grouping, never rendered
columnHelper.accessor((row) => row.meta?.folder ?? "", {
id: "folder",
enableGrouping: true,
enableSorting: false,
header: () => null,
cell: () => null,
}),
columnHelper.accessor((row: any) => row.owner, {
id: "owner",
cell: (info: any) => {
Expand Down Expand Up @@ -131,6 +172,16 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
const tableInstance = useReactTable<DeadHost>({
columns,
data,
state: {
grouping,
expanded,
columnVisibility: { folder: false },
},
onExpandedChange: (updater) => {
onExpandedChange(typeof updater === "function" ? updater(expanded) : updater);
},
getGroupedRowModel: getGroupedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getCoreRowModel: getCoreRowModel(),
rowCount: data.length,
meta: {
Expand All @@ -139,9 +190,67 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
enableSortingRemoval: false,
});

const visibleColumnCount = tableInstance.getVisibleLeafColumns().length;

const renderLeafRow = (row: Row<DeadHost>): ReactNode => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => {
const { className } = (cell.column.columnDef.meta as any) ?? {};
return (
<td key={cell.id} className={className}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);

const renderRow = (row: Row<DeadHost>): ReactNode => {
if (row.getIsGrouped()) {
const folderName = row.groupingValue as string;
if (!folderName) {
// Ungrouped hosts: render leaf rows directly at the top, no folder header.
// Wrapped in a Fragment so TableBody's cloneElement can apply key={row.id}.
return <>{row.subRows.map((subRow) => renderLeafRow(subRow))}</>;
}
const enabledCount = row.subRows.filter((r) => r.original?.enabled).length;
const disabledCount = row.subRows.length - enabledCount;
return (
<tr
key={row.id}
style={{
backgroundColor: "var(--tblr-bg-surface-secondary, #f6f8fb)",
cursor: "pointer",
userSelect: "none",
}}
onClick={() => row.toggleExpanded()}
>
<td colSpan={visibleColumnCount} className="py-2 px-3">
<span className="me-2 text-muted">
{row.getIsExpanded() ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
</span>
<strong>{folderName}</strong>
<span className="status status-lime ms-3">
<span className="status-dot status-dot-animated" />
{enabledCount}
</span>
{disabledCount > 0 && (
<span className="status status-red ms-2">
<span className="status-dot status-dot-animated" />
{disabledCount}
</span>
)}
</td>
</tr>
);
}
return renderLeafRow(row);
};

return (
<TableLayout
tableInstance={tableInstance}
renderRow={renderRow}
emptyState={
<EmptyData
object="dead-host"
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IconHelp, IconSearch } from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import type { ExpandedState } from "@tanstack/react-table";
import { useState } from "react";
import Alert from "react-bootstrap/Alert";
import { deleteDeadHost, toggleDeadHost } from "src/api/backend";
Expand All @@ -14,6 +15,7 @@ import Table from "./Table";
export default function TableWrapper() {
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<ExpandedState>({});
const { isFetching, isLoading, isError, error, data } = useDeadHosts(["owner", "certificate"]);

if (isLoading) {
Expand All @@ -39,7 +41,10 @@ export default function TableWrapper() {
let filtered = null;
if (search && data) {
filtered = data?.filter((item) => {
return item.domainNames.some((domain: string) => domain.toLowerCase().includes(search));
return (
item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) ||
(item.meta?.folder ?? "").toLowerCase().includes(search)
);
});
} else if (search !== "") {
// this can happen if someone deletes the last item while searching
Expand Down Expand Up @@ -92,6 +97,8 @@ export default function TableWrapper() {
data={filtered ?? data ?? []}
isFiltered={!!search}
isFetching={isFetching}
expanded={expanded}
onExpandedChange={setExpanded}
onEdit={(id: number) => showDeadHostModal(id)}
onDelete={(id: number) =>
showDeleteConfirmModal({
Expand Down
Loading