| 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 |