main
ts 295 lines 14.8 KB
Raw
1 import { DataGrid, DataGridProps, getGridStringOperators, GridColDef, GridFooter, GridFooterContainer,
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, 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, 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'
14
15 const ACTIONS = 'Actions'
16
17 export type DataTableColumn<R extends GridValidRowModel=any> = GridColDef<R> & {
18 hideUnder?: Breakpoint | number | boolean
19 dialogHidden?: boolean
20 sx?: SxProps | Callback<GridRenderCellParams, SxProps>
21 mergeRender?: { [other: string]: false | { override?: Partial<GridColDef<R>> } & BoxProps }
22 mergeRenderSx?: SxProps
23 cellInnerProps?: BoxProps
24 }
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
33 error?: ReactNode
34 compact?: boolean
35 footerSide?: (width: number) => ReactNode
36 fillFlex?: boolean
37 persist?: string
38 details?: boolean
39 }
40 export function DataTable({
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
51 const ret = onlyTruthy(columns.map(col => {
52 if (!col) return
53 const { type, sx } = col
54 if (!type || type === 'string') // offer negated version of default string operators
55 col.filterOperators ??= getGridStringOperators().flatMap(op => op.value.includes('Empty') ? op : [ // isEmpty already has isNotEmpty
56 op,
57 {
58 ...op,
59 value: '!' + op.value,
60 getApplyFilterFn(item, col) {
61 const res = op.getApplyFilterFn(item, col)
62 return res && _.negate(res)
63 },
64 label: "(not) " + (localeText['filterOperator' + _.upperFirst(op.value)] || op.value)
65 } satisfies typeof op
66 ])
67 if (!col.mergeRender && !col.sx)
68 return col
69 return {
70 ...col,
71 originalRenderCell: col.renderCell || true,
72 renderCell(params: GridRenderCellParams) {
73 const { columns } = params.api.store.getSnapshot()
74 return h(Box, { ...col.cellInnerProps, sx: { maxHeight: '100%', textWrap: 'wrap', lineHeight: '1.2em', ...callable(sx as any, params) } }, // wrap if necessary, but stay within the row
75 col.renderCell ? col.renderCell(params) : params.formattedValue,
76 col.mergeRender && h(Flex, { fontSize: 'smaller', flexWrap: 'wrap', mt: '1px', rowGap: 0, ...col.mergeRenderSx }, // wrap, normally causing overflow/hiding, if it doesn't fit
77 ...onlyTruthy(_.map(col.mergeRender, (props, other) => {
78 if (!props || columns.columnVisibilityModel[other] !== false) return null
79 const rendered = renderCell({ ...columns.lookup[other], ...props.override }, params.row)
80 // keep mergeRender permissive for editor autocomplete, then narrow only at the render boundary
81 const { override, sx, ...boxProps } = props
82 return rendered && h(Box as any, { ...boxProps, sx: mergeSx(sx, compact && { lineHeight: '1em' }) }, rendered)
83 }))
84 )
85 )
86 }
87 }
88 }))
89 if (actions)
90 ret.unshift({
91 field: ACTIONS,
92 width: 40 * actionsLength,
93 headerName: '',
94 align: 'center',
95 headerAlign: 'center',
96 sortable: false,
97 hideSortIcons: true,
98 disableColumnMenu: true,
99 renderCell(params: any) {
100 const ret = actions({ ...params.row, ...params })
101 setTimeout(() => setActionsLength(ret.length)) // cannot update state during rendering
102 return h(Box, { sx: { whiteSpace: 'nowrap' } }, ...ret)
103 },
104 ...actionsProps,
105 renderHeader: quickFilter || actionsHeader ? renderActionsHeader : actionsProps?.renderHeader,
106 })
107 return ret
108 }, [columns, actions, actionsHeader, actionsLength, actionsProps, quickFilter])
109 const sizeGrid = useGetSize()
110 const width = useDebounce(sizeGrid.w || 0, 100) // stabilize width
111 const hideCols = useMemo(() => {
112 const fields = onlyTruthy(manipulatedColumns.map(({ field, hideUnder }) =>
113 (hideUnder === true || hideUnder && width < (typeof hideUnder === 'number' ? hideUnder : theme.breakpoints.values[hideUnder]))
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
122 }, [manipulatedColumns, width, quickFilter])
123 const [vis, setVis] = useState(persist && state.dataTablePersistence[persist]?.columnVisibility || {})
124
125 const displayingDetails = useRef<any>({})
126 useEffect(() => {
127 const { current: { id, setCurRow } } = displayingDetails
128 setCurRow?.(_.find(rest.rows, { id }))
129 })
130 const sizeFooterSide = useGetSize()
131 const wrappedFooterSide = h(Box, {
132 ref: sizeFooterSide.refToPass,
133 className: 'footerSide',
134 sx: { whiteSpace: 'nowrap' }
135 }, footerSide?.(width))
136 const [causingScrolling, setCausingScrolling] = useState(false)
137 const updateCausingScrolling = useCallback(_.debounce(() => {
138 const el = sizeGrid.ref.current?.querySelector('.MuiTablePagination-root')
139 setCausingScrolling(el && (el.scrollWidth > el.clientWidth) || false)
140 }, 500), [sizeGrid])
141 useEffect(updateCausingScrolling, [sizeGrid, width, sizeFooterSide.w]) // recalculate in case the footerSide changes
142
143 return h(Fragment, {},
144 error && h(Alert, { severity: 'error' }, error),
145 initializing && h(Box, { sx: { position: 'relative' } },
146 h(LinearProgress, { // differently from "loading", this is not blocking user interaction
147 sx: { position: 'absolute', width: 'calc(100% - 2px)', borderRadius: 1, m: '1px 1px' }
148 }) ),
149 h(DataGrid, {
150 key: width,
151 initialState,
152 density: compact ? 'compact' : 'standard',
153 columns: manipulatedColumns,
154 apiRef,
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
162 '& .MuiTablePagination-root': { scrollbarWidth: 'none'},
163 }, rest.sx),
164 slots: {
165 footer: CustomFooter,
166 noRowsOverlay: NoRowsOverlay,
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: {
176 labelRowsPerPage: "Rows",
177 ...!causingScrolling && {
178 showFirstButton: true,
179 showLastButton: true,
180 },
181 ...(slotProps as any)?.pagination,
182 },
183 },
184 onCellClick({ field, row }) {
185 if (field === ACTIONS || details === false) return
186 if (window.getSelection()?.type === 'Range') return // not a click but a drag
187 const visibleInList = merged + (apiRef.current?.getVisibleColumns().length || 0)
188 const showInDialog = manipulatedColumns.filter(x =>
189 !x.dialogHidden && (x.renderCell || x.valueGetter || x.field === ACTIONS || row[x.field] !== undefined))
190 if (showInDialog.length <= visibleInList) return // no need for dialog
191 newDialog({
192 title: "Details",
193 onClose() {
194 displayingDetails.current = {}
195 },
196 Content() {
197 const [curRow, setCurRow] = useState(row)
198 const keepRow = useRef(row)
199 if (curRow)
200 keepRow.current = curRow
201 const rowToShow = keepRow.current
202 displayingDetails.current = { id: rowToShow.id, setCurRow }
203 return h(Box, {
204 sx: {
205 display: 'grid',
206 gridTemplateColumns: 'repeat(auto-fill, minmax(8em,1fr))',
207 gap: '1em',
208 gridAutoFlow: 'dense',
209 minWidth: 'max(16em, 40vw)',
210 opacity: curRow ? undefined : .5,
211 },
212 }, showInDialog.map(col =>
213 h(Box, { key: col.field, sx: { gridColumn: col.flex! >= 1 ? '1/-1' : undefined } },
214 h(Box, { sx: { bgcolor: '#0003', p: 1 } }, col.headerName || col.field),
215 h(Flex, { minHeight: '2.5em', px: 1, wordBreak: 'break-word', flexWrap: 'wrap' },
216 renderCell(col, rowToShow) )
217 ) ))
218 }
219 })
220 },
221 onColumnVisibilityModelChange: vis => {
222 setVis(vis)
223 if (!persist) return
224 updateStateObject(state, 'dataTablePersistence', x => {
225 x[persist] = {
226 columnVisibility: _.omitBy(vis, (v, k) => hideCols.includes(k) === (v === false))
227 }
228 })
229 },
230 columnVisibilityModel: {
231 ...Object.fromEntries(hideCols.map(x => [x, false])),
232 ...rest.columnVisibilityModel,
233 ...vis,
234 }
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]
252 if (col.valueGetter) // @ts-ignore
253 value = col.valueGetter(value, row, col, api)
254 const render = (col as any).originalRenderCell || col.renderCell
255 return render && render !== true ? render({ value, row, api, ...row })
256 // @ts-ignore
257 : col.valueFormatter ? col.valueFormatter(value, row, col, api)
258 : value
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 }
289
290 function NoRowsOverlay({ initializing, noRows }: { initializing?: boolean, noRows?: ReactNode }) {
291 return initializing ? null : h(Center, {}, noRows || "No entries")
292 }
293
294 // required in case of fillFlex:true
295 export const fillFlexParentSx = { display: 'flex', flexDirection: 'column' } as const