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,