diff --git a/frontend/src/components/FolderField.tsx b/frontend/src/components/FolderField.tsx new file mode 100644 index 0000000000..6a82584b73 --- /dev/null +++ b/frontend/src/components/FolderField.tsx @@ -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({ 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, _actionMeta: ActionMeta) => { + helpers.setValue(newValue?.value || undefined); + }; + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/Table/TableBody.tsx b/frontend/src/components/Table/TableBody.tsx index d8fe9bf3a4..7e495cc246 100644 --- a/frontend/src/components/Table/TableBody.tsx +++ b/frontend/src/components/Table/TableBody.tsx @@ -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(props: TableLayoutProps) { - const { tableInstance, extraStyles, emptyState } = props; + const { tableInstance, extraStyles, emptyState, renderRow } = props; const rows = tableInstance.getRowModel().rows; if (rows.length === 0) { @@ -16,7 +17,11 @@ function TableBody(props: TableLayoutProps) { return ( - {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 ( {row.getVisibleCells().map((cell: any) => { diff --git a/frontend/src/components/Table/TableLayout.tsx b/frontend/src/components/Table/TableLayout.tsx index 630340919d..62e85c8d63 100644 --- a/frontend/src/components/Table/TableLayout.tsx +++ b/frontend/src/components/Table/TableLayout.tsx @@ -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"; @@ -8,6 +9,7 @@ interface TableLayoutProps { extraStyles?: { row: (rowData: TFields) => any | undefined; }; + renderRow?: (row: Row) => ReactNode; } function TableLayout(props: TableLayoutProps) { const hasRows = props.tableInstance.getRowModel().rows.length > 0; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 861f637e14..1e630b95b4 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -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"; diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..ecc30004c7 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -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" }, diff --git a/frontend/src/modals/DeadHostModal.tsx b/frontend/src/modals/DeadHostModal.tsx index c9dce9a1c4..f117c197b6 100644 --- a/frontend/src/modals/DeadHostModal.tsx +++ b/frontend/src/modals/DeadHostModal.tsx @@ -7,6 +7,7 @@ import Modal from "react-bootstrap/Modal"; import { Button, DomainNamesField, + FolderField, Loading, NginxConfigField, SSLCertificateField, @@ -132,6 +133,7 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
+
{
+

diff --git a/frontend/src/modals/RedirectionHostModal.tsx b/frontend/src/modals/RedirectionHostModal.tsx index 1bd7877f4f..f7f3057f43 100644 --- a/frontend/src/modals/RedirectionHostModal.tsx +++ b/frontend/src/modals/RedirectionHostModal.tsx @@ -8,6 +8,7 @@ import Modal from "react-bootstrap/Modal"; import { Button, DomainNamesField, + FolderField, Loading, NginxConfigField, SSLCertificateField, @@ -162,7 +163,9 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = required {...field} > - + @@ -242,6 +245,7 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =

)} +

diff --git a/frontend/src/modals/StreamModal.tsx b/frontend/src/modals/StreamModal.tsx index 6d55348896..1323b7e032 100644 --- a/frontend/src/modals/StreamModal.tsx +++ b/frontend/src/modals/StreamModal.tsx @@ -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"; @@ -280,6 +280,7 @@ const StreamModal = EasyModal.create(({ id, visible, remove }: Props) => {

+
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(); + + 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) => { @@ -131,6 +172,16 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog const tableInstance = useReactTable({ 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: { @@ -139,9 +190,67 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog enableSortingRemoval: false, }); + const visibleColumnCount = tableInstance.getVisibleLeafColumns().length; + + const renderLeafRow = (row: Row): ReactNode => ( + + {row.getVisibleCells().map((cell) => { + const { className } = (cell.column.columnDef.meta as any) ?? {}; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ); + + const renderRow = (row: Row): 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 ( + row.toggleExpanded()} + > + + + {row.getIsExpanded() ? : } + + {folderName} + + + {enabledCount} + + {disabledCount > 0 && ( + + + {disabledCount} + + )} + + + ); + } + return renderLeafRow(row); + }; + return ( ({}); const { isFetching, isLoading, isError, error, data } = useDeadHosts(["owner", "certificate"]); if (isLoading) { @@ -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 @@ -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({ diff --git a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx index 9d58b26acd..870cab4e0c 100644 --- a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx +++ b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx @@ -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 { ProxyHost } from "src/api/backend"; import { @@ -19,22 +37,46 @@ interface Props { data: ProxyHost[]; 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(); + + 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) => { const value = info.getValue(); return ; }, - meta: { + meta: { className: "w-1", }, }), @@ -77,68 +119,64 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog }), columnHelper.display({ id: "id", - cell: (info: any) => { - return ( - - +
+ + + + { + e.preventDefault(); + onEdit?.(info.row.original.id); + }} > - - -
- - - + + + + { e.preventDefault(); - onEdit?.(info.row.original.id); + onDisableToggle?.(info.row.original.id, !info.row.original.enabled); }} > - - + + - - { - e.preventDefault(); - onDisableToggle?.(info.row.original.id, !info.row.original.enabled); - }} - > - - - - - - ); - }, - meta: { - className: "text-end w-1", - }, + + + ), + meta: { className: "text-end w-1" }, }), ], [columnHelper, onEdit, onDisableToggle, onDelete], @@ -147,17 +185,83 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog const tableInstance = useReactTable({ 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: { - isFetching, - }, + meta: { isFetching }, enableSortingRemoval: false, }); + const visibleColumnCount = tableInstance.getVisibleLeafColumns().length; + + const renderLeafRow = (row: Row): ReactNode => ( + + {row.getVisibleCells().map((cell) => { + const { className } = (cell.column.columnDef.meta as any) ?? {}; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ); + + const renderRow = (row: Row): 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 ( + row.toggleExpanded()} + > + + + {row.getIsExpanded() ? : } + + {folderName} + + + {enabledCount} + + {disabledCount > 0 && ( + + + {disabledCount} + + )} + + + ); + } + return renderLeafRow(row); + }; + return ( ({}); const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"]); if (isLoading) { @@ -42,7 +44,8 @@ export default function TableWrapper() { (item) => item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) || item.forwardHost.toLowerCase().includes(search) || - `${item.forwardPort}`.includes(search), + `${item.forwardPort}`.includes(search) || + (item.meta?.folder ?? "").toLowerCase().includes(search), ); } else if (search !== "") { // this can happen if someone deletes the last item while searching @@ -98,6 +101,8 @@ export default function TableWrapper() { data={filtered ?? data ?? []} isFiltered={!!search} isFetching={isFetching} + expanded={expanded} + onExpandedChange={setExpanded} onEdit={(id: number) => showProxyHostModal(id)} onDelete={(id: number) => showDeleteConfirmModal({ diff --git a/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx b/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx index 6ac4152348..1640f7bdaa 100644 --- a/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx +++ b/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx @@ -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 { RedirectionHost } from "src/api/backend"; import { @@ -18,15 +36,38 @@ interface Props { data: RedirectionHost[]; 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(); + + 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) => { @@ -152,6 +193,16 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog const tableInstance = useReactTable({ 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: { @@ -160,9 +211,67 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog enableSortingRemoval: false, }); + const visibleColumnCount = tableInstance.getVisibleLeafColumns().length; + + const renderLeafRow = (row: Row): ReactNode => ( + + {row.getVisibleCells().map((cell) => { + const { className } = (cell.column.columnDef.meta as any) ?? {}; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ); + + const renderRow = (row: Row): 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 ( + row.toggleExpanded()} + > + + + {row.getIsExpanded() ? : } + + {folderName} + + + {enabledCount} + + {disabledCount > 0 && ( + + + {disabledCount} + + )} + + + ); + } + return renderLeafRow(row); + }; + return ( ({}); const { isFetching, isLoading, isError, error, data } = useRedirectionHosts(["owner", "certificate"]); if (isLoading) { @@ -41,7 +43,8 @@ export default function TableWrapper() { filtered = data?.filter((item) => { return ( item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) || - item.forwardDomainName.toLowerCase().includes(search) + item.forwardDomainName.toLowerCase().includes(search) || + (item.meta?.folder ?? "").toLowerCase().includes(search) ); }); } else if (search !== "") { @@ -98,6 +101,8 @@ export default function TableWrapper() { data={filtered ?? data ?? []} isFiltered={!!search} isFetching={isFetching} + expanded={expanded} + onExpandedChange={setExpanded} onEdit={(id: number) => showRedirectionHostModal(id)} onDelete={(id: number) => showDeleteConfirmModal({ diff --git a/frontend/src/pages/Nginx/Streams/Table.tsx b/frontend/src/pages/Nginx/Streams/Table.tsx index 4b9ff7d600..402b3b1bc4 100644 --- a/frontend/src/pages/Nginx/Streams/Table.tsx +++ b/frontend/src/pages/Nginx/Streams/Table.tsx @@ -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 { Stream } from "src/api/backend"; import { @@ -18,15 +36,38 @@ interface Props { data: Stream[]; 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, isFiltered, onEdit, onDelete, onDisableToggle, onNew }: Props) { +export default function Table({ + data, + isFetching, + isFiltered, + expanded, + onExpandedChange, + onEdit, + onDelete, + onDisableToggle, + onNew, +}: Props) { const columnHelper = createColumnHelper(); + + 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) => { @@ -160,6 +201,16 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, const tableInstance = useReactTable({ 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: { @@ -168,9 +219,67 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, enableSortingRemoval: false, }); + const visibleColumnCount = tableInstance.getVisibleLeafColumns().length; + + const renderLeafRow = (row: Row): ReactNode => ( + + {row.getVisibleCells().map((cell) => { + const { className } = (cell.column.columnDef.meta as any) ?? {}; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ); + + const renderRow = (row: Row): ReactNode => { + if (row.getIsGrouped()) { + const folderName = row.groupingValue as string; + if (!folderName) { + // Ungrouped streams: 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 ( + row.toggleExpanded()} + > + + + {row.getIsExpanded() ? : } + + {folderName} + + + {enabledCount} + + {disabledCount > 0 && ( + + + {disabledCount} + + )} + + + ); + } + return renderLeafRow(row); + }; + return ( ({}); const { isFetching, isLoading, isError, error, data } = useStreams(["owner", "certificate"]); if (isLoading) { @@ -43,7 +44,8 @@ export default function TableWrapper() { return ( `${item.incomingPort}`.includes(search) || `${item.forwardingPort}`.includes(search) || - item.forwardingHost.includes(search) + item.forwardingHost.includes(search) || + (item.meta?.folder ?? "").toLowerCase().includes(search) ); }); } else if (search !== "") { @@ -95,7 +97,9 @@ export default function TableWrapper() { showStreamModal(id)} onDelete={(id: number) => showDeleteConfirmModal({