admin: quick-filter on plugins and some other tables

Massimo Melina committed May 2, 2026 at 01:00 UTC 821d005fe4f37fdf6fc116678c1d7483f1a2d137
3 files changed +58 -11
admin/src/DataTable.ts
+55 -9
@@ -1,12 +1,13 @@
1 import { DataGrid, DataGridProps, getGridStringOperators, GridColDef, GridFooter, GridFooterContainer,
2 - GridValidRowModel, useGridApiRef, GridRenderCellParams } from '@mui/x-data-grid'
2 + GridValidRowModel, useGridApiRef, GridRenderCellParams, QuickFilter, QuickFilterControl } from '@mui/x-data-grid'
3 import { enUS } from '@mui/x-data-grid/locales'
4 import { Alert, Box, BoxProps, LinearProgress, useTheme } from '@mui/material'
5 import type { Breakpoint } from '@mui/material/styles'
6 -import { createElement as h, Fragment, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
6 +import { createElement as h, type ElementType, Fragment, ReactNode, type SyntheticEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
7 import { callable, Callback, Falsy, newDialog, onlyTruthy, useGetSize } from '@hfs/shared'
8 import _ from 'lodash'
9 -import { Center, Flex, mergeSx } from './mui'
9 +import { Center, Flex, IconBtn, mergeSx } from './mui'
10 +import { Search } from '@mui/icons-material'
11 import { SxProps } from '@mui/system'
12 import { state, updateStateObject } from './state'
13 import { useDebounce } from 'usehooks-ts'
@@ -24,6 +25,8 @@ export type DataTableColumn<R extends GridValidRowModel=any> = GridColDef<R> & {
25 export interface DataTableProps<R extends GridValidRowModel=any> extends Omit<DataGridProps<R>, 'columns'> {
26 columns: Array<DataTableColumn<R> | Falsy>
27 actions?: ({ row, id }: any) => ReactNode[]
28 + actionsHeader?: ReactNode | Callback<any, ReactNode>
29 + quickFilter?: boolean
30 actionsProps?: Partial<GridColDef<R>> & { hideUnder?: Breakpoint | number }
31 initializing?: boolean
32 noRows?: ReactNode
@@ -35,12 +38,13 @@ export interface DataTableProps<R extends GridValidRowModel=any> extends Omit<Da
38 details?: boolean
39 }
40 export function DataTable({
38 - columns, initialState={}, actions, actionsProps, initializing, noRows, error, compact, footerSide, fillFlex,
39 - persist, details, slots, slotProps, ...rest
41 + columns, initialState={}, actions, actionsHeader, actionsProps, initializing, noRows, error, compact, footerSide, fillFlex,
42 + persist, details, quickFilter, slots, slotProps, ...rest
43 }: DataTableProps) {
44 const theme = useTheme()
45 const apiRef = useGridApiRef()
46 const [actionsLength, setActionsLength] = useState(0)
47 + const [quickFilterOpen, setQuickFilterOpen] = useState(false)
48 const [merged, setMerged] = useState(0)
49 const manipulatedColumns = useMemo(() => {
50 const { localeText } = enUS.components.MuiDataGrid.defaultProps as any
@@ -89,6 +93,7 @@ export function DataTable({
93 headerName: '',
94 align: 'center',
95 headerAlign: 'center',
96 + sortable: false,
97 hideSortIcons: true,
98 disableColumnMenu: true,
99 renderCell(params: any) {
@@ -96,10 +101,11 @@ export function DataTable({
101 setTimeout(() => setActionsLength(ret.length)) // cannot update state during rendering
102 return h(Box, { sx: { whiteSpace: 'nowrap' } }, ...ret)
103 },
99 - ...actionsProps
104 + ...actionsProps,
105 + renderHeader: quickFilter || actionsHeader ? renderActionsHeader : actionsProps?.renderHeader,
106 })
107 return ret
102 - }, [columns, actions, actionsLength])
108 + }, [columns, actions, actionsHeader, actionsLength, actionsProps, quickFilter])
109 const sizeGrid = useGetSize()
110 const width = useDebounce(sizeGrid.w || 0, 500) // stabilize width
111 const hideCols = useMemo(() => {
@@ -108,10 +114,12 @@ export function DataTable({
114 && field))
115 const o = Object.fromEntries(fields.map(x => [x, false]))
116 _.merge(initialState, { columns: { columnVisibilityModel: o } })
117 + if (quickFilter)
118 + _.merge(initialState, { filter: { filterModel: { quickFilterExcludeHiddenColumns: false } } })
119 // count the hidden columns that are merged into visible columns
120 setMerged(_.sumBy(fields, k => _.find(columns, col => col && !fields.includes(col.field) && col.mergeRender?.[k]) ? 1 : 0))
121 return fields
114 - }, [manipulatedColumns, width])
122 + }, [manipulatedColumns, width, quickFilter])
123 const [vis, setVis] = useState(persist && state.dataTablePersistence[persist]?.columnVisibility || {})
124
125 const displayingDetails = useRef<any>({})
@@ -147,6 +155,7 @@ export function DataTable({
155 disableRowSelectionOnClick: true,
156 ref: sizeGrid.refToPass,
157 ...rest,
158 + ...quickFilter && { showToolbar: quickFilterOpen || rest.showToolbar },
159 sx: mergeSx({
160 ...fillFlex && { height: 0, flex: 'auto' }, // limit table to available screen space, if parent is flex. Consider using fillFlexParentSx
161 '& .MuiDataGrid-virtualScroller': { minHeight: '3em' }, // without this, no-entries gets just 1px
@@ -155,10 +164,12 @@ export function DataTable({
164 slots: {
165 footer: CustomFooter,
166 noRowsOverlay: NoRowsOverlay,
158 - ...slots
167 + ...slots,
168 + ...quickFilterOpen && { toolbar: DataTableQuickFilterToolbar as any }
169 } as any,
170 slotProps: {
171 ...slotProps,
172 + ...quickFilterOpen && { toolbar: { ...(slotProps as any)?.toolbar, onExpandedChange: setQuickFilterOpen } },
173 footer: { ...(slotProps as any)?.footer, add: wrappedFooterSide },
174 noRowsOverlay: { ...(slotProps as any)?.noRowsOverlay, initializing, noRows },
175 pagination: {
@@ -224,6 +235,17 @@ export function DataTable({
235 })
236 )
237
238 + function renderActionsHeader(params: any) {
239 + return h(Box, { sx: { display: 'flex', width: '100%', justifyContent: 'center' }, onClick: stopPropagation, onKeyDown: stopPropagation },
240 + actionsHeader !== undefined ? callable(actionsHeader, params) : actionsProps?.renderHeader?.(params),
241 + quickFilter && h(IconBtn, { icon: Search, title: "Search", size: 'small', onClick: () => setQuickFilterOpen(true) }))
242 +
243 + function stopPropagation(ev: SyntheticEvent) {
244 + // prevent header controls from triggering grid sorting or column interactions
245 + ev.stopPropagation()
246 + }
247 + }
248 +
249 function renderCell(col: GridColDef, row: any) {
250 const api = apiRef.current
251 let value = row[col.field]
@@ -237,6 +259,30 @@ export function DataTable({
259 }
260 }
261
262 +function DataTableQuickFilterToolbar({ onExpandedChange }: {
263 + onExpandedChange?: (expanded: boolean) => void
264 +}) {
265 + const inputRef = useRef<HTMLInputElement>(null)
266 + useEffect(() => {
267 + // focus after mount because the toolbar is created only after the header search button is pressed
268 + requestAnimationFrame(() => inputRef.current?.focus())
269 + }, [])
270 + return h(Box, {
271 + sx: {
272 + p: '4px 8px',
273 + '.MuiFormControl-root': { width: '100%' },
274 + '.MuiInputBase-root': { height: 36 },
275 + '.MuiInputAdornment-positionStart': {
276 + // MUI filled inputs reserve label space for adornments, but this toolbar field has no label
277 + mt: '3px !important',
278 + },
279 + '.MuiInputBase-input': { pt: '7px', pb: '6px' },
280 + }
281 + },
282 + h(QuickFilter, { expanded: true, debounceMs: 300, onExpandedChange },
283 + h(QuickFilterControl as ElementType, { fullWidth: true, inputRef, size: 'small', placeholder: "Search" })))
284 +}
285 +
286 function CustomFooter({ add, ...props }: { add?: ReactNode }) {
287 return h(GridFooterContainer, props, h(Box, { sx: { ml: { sm: 1 } } }, add), h(GridFooter, { sx: { border: 'none' } }))
288 }
admin/src/InstalledPlugins.ts
+2 -1
@@ -44,6 +44,8 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
44 fillFlex: true,
45 initializing,
46 disableColumnSelector: true,
47 + quickFilter: !updates,
48 + actionsHeader: !updates && pauseButton,
49 getRowHeight: updates && (({ model }) => model.changelog ? 'auto' as const : 50),
50 noRows: updates && `No updates available. Only plugins available on "search online" are checked.`,
51 columns: [
@@ -95,7 +97,6 @@ export default function InstalledPlugins({ updates }: { updates?: true }) {
97 }
98 }
99 ],
98 - footerSide: () => !updates && pauseButton,
100 actions: ({ row, id }) => updates ? [
101 h(IconBtn, {
102 icon: Upgrade,
admin/src/MonitorPage.ts
+1 -1
@@ -149,8 +149,8 @@ function Connections() {
149 rows,
150 fillFlex: true,
151 noRows: monitorOnlyFiles && "No downloads/uploads at the moment",
152 + actionsHeader: pauseButton,
153 footerSide: () => h(Flex, {},
153 - pauseButton,
154 h(Btn, {
155 size: 'small',
156 icon: DisconnectIcon,