| 1 | import { createElement as h, Fragment, isValidElement, useMemo, useState } from 'react' |
| 2 | import { callable, Dict, formatTimestamp, Functionable, isOrderedEqual, setHidden, swap } from './misc' |
| 3 | import { Add, Edit, Delete, ArrowUpward, ArrowDownward, Undo, Check } from '@mui/icons-material' |
| 4 | import { DialogOptions, FormDialog, formDialog } from './dialog' |
| 5 | import { GridActionsCellItem, GridAlignment, GridColDef } from '@mui/x-data-grid' |
| 6 | import { BoolField, FieldDescriptor, FieldProps, labelFromKey } from '@hfs/mui-grid-form' |
| 7 | import { Box, FormHelperText, FormLabel } from '@mui/material' |
| 8 | import _ from 'lodash' |
| 9 | import { Center, Flex, IconBtn, mergeSx, useBreakpoint } from './mui' |
| 10 | import { DataTable, DataTableColumn } from './DataTable' |
| 11 | import { DateTimeField } from './DateTimeField' |
| 12 | |
| 13 | type ArrayFieldProps<T> = FieldProps<T[] | Dict<T>> & { |
| 14 | fields: Functionable<FieldDescriptor[] & { |
| 15 | $width?: number, // 8 or more is px, less is flex |
| 16 | $column?: Partial<DataTableColumn>, |
| 17 | $type?: string |
| 18 | $hideUnder?: DataTableColumn['hideUnder'], |
| 19 | showIf: (values: any) => unknown, // truthy |
| 20 | $render?: GridColDef['renderCell'], |
| 21 | $mergeRender: DataTableColumn['mergeRender'], |
| 22 | }>, |
| 23 | height?: number, |
| 24 | reorder?: boolean, |
| 25 | prepend?: boolean, |
| 26 | autoRowHeight?: boolean, |
| 27 | form?: Partial<FormDialog<any>>, |
| 28 | dialog?: Partial<DialogOptions>, |
| 29 | details?: boolean |
| 30 | objectK?: string |
| 31 | saveOn?: 'change' | 'close' |
| 32 | } |
| 33 | export function ArrayField<T extends object>({ |
| 34 | label, helperText, fields, value, onChange, onError, setApi, reorder, prepend, noRows, valuesForAdd, autoRowHeight, |
| 35 | dialog, form, details, objectK, saveOn, height, error, sx, ...rest |
| 36 | }: ArrayFieldProps<T>) { |
| 37 | const valueA = Array.isArray(value) ? value |
| 38 | : !objectK || !value ? [] // avoid crash if non-array values are passed, especially developing plugins |
| 39 | : Object.entries(value).map(([k, v]) => ({ [objectK]: k, ...v })) |
| 40 | const rows = useMemo(() => valueA!.map((x,$idx) => |
| 41 | setHidden({ ...x } as any, x.hasOwnProperty('id') ? { $idx } : { id: $idx })), |
| 42 | [JSON.stringify(valueA)]) //eslint-disable-line |
| 43 | const fieldError = Boolean(error) || undefined |
| 44 | const getFormProp = (more: any) => (values: any) => ({ |
| 45 | fields: callable(fields, values).map(({ $width, $column, $type, $hideUnder, showIf, $render, $mergeRender, ...rest }) => |
| 46 | (!showIf || showIf(values)) && _.defaults(rest, byType[$type]?.field)), |
| 47 | ...form, |
| 48 | ...more, |
| 49 | }) |
| 50 | setApi?.({ isEqual: isOrderedEqual }) // don't rely on stringify, as it wouldn't work with non-json values |
| 51 | const [undo, setUndo] = useState<typeof valueA>() |
| 52 | return h(Fragment, {}, |
| 53 | h(Flex, { rowGap: 0, flexWrap: 'wrap', ml: '2px' }, |
| 54 | label && h(FormLabel, { error: fieldError, sx: { color: fieldError ? undefined : 'text.primary' } }, label), |
| 55 | helperText && h(FormHelperText, { error: fieldError }, helperText), |
| 56 | ), |
| 57 | // field-level error is rendered through helperText, not forwarded to the DOM wrapper |
| 58 | h(Box, { ...rest, sx: mergeSx({ height }, sx) }, |
| 59 | h(DataTable, { |
| 60 | rows, |
| 61 | details, |
| 62 | ...autoRowHeight && { getRowHeight: () => 'auto' as const }, |
| 63 | ...!useBreakpoint('sm') && { compact: true }, |
| 64 | sx: { |
| 65 | '.MuiDataGrid-virtualScroller': { minHeight: '3em' }, |
| 66 | ...autoRowHeight && { '.MuiDataGrid-cell': { minHeight: '52px !important' } } |
| 67 | }, |
| 68 | hideFooterSelectedRowCount: true, |
| 69 | hideFooter: true, |
| 70 | slots: { |
| 71 | noRowsOverlay: () => h(Center, {}, noRows || "No entries"), |
| 72 | }, |
| 73 | slotProps: { |
| 74 | pagination: { |
| 75 | showFirstButton: true, |
| 76 | showLastButton: true, |
| 77 | } |
| 78 | }, |
| 79 | columns: [ |
| 80 | ...callable(fields, false).map(f => { |
| 81 | if (isValidElement(f)) return |
| 82 | const def = byType[f.$type]?.column |
| 83 | return _.defaults({ |
| 84 | field: f.k, |
| 85 | headerName: f.headerName ?? (typeof f.label === 'string' ? f.label : labelFromKey(f.k)), |
| 86 | disableColumnMenu: true, |
| 87 | valueGetter: (v: any) => (f.toField || _.identity)(v), |
| 88 | ...f.$width ? { [f.$width >= 8 ? 'width' : 'flex']: f.$width } : (!def?.width && !def?.flex && { flex: 1 }), |
| 89 | renderCell: f.$render, |
| 90 | mergeRender: f.$mergeRender, |
| 91 | hideUnder: f.$hideUnder, |
| 92 | ...f.$column, |
| 93 | }, def) |
| 94 | }).filter(Boolean), |
| 95 | { |
| 96 | field: '', |
| 97 | type: 'actions', |
| 98 | width: 90, |
| 99 | headerAlign: 'center' as GridAlignment, |
| 100 | renderHeader(){ |
| 101 | const title = "Add" |
| 102 | return h(Fragment, {}, |
| 103 | h(IconBtn, { |
| 104 | icon: Add, |
| 105 | title, |
| 106 | size: 'small', |
| 107 | async onClick(ev) { |
| 108 | const res = await formDialog<T>({ |
| 109 | form: getFormProp({}), |
| 110 | title, |
| 111 | values: valuesForAdd, |
| 112 | dialogProps: dialog |
| 113 | }) |
| 114 | if (!res) return |
| 115 | const newValue = valueA.slice() || [] |
| 116 | if (prepend) newValue.unshift(res) |
| 117 | else newValue.push(res) |
| 118 | set(newValue, ev) |
| 119 | } |
| 120 | }), |
| 121 | undo !== undefined && h(IconBtn, { |
| 122 | icon: Undo, |
| 123 | title: "Undo", |
| 124 | size: 'small', |
| 125 | onClick: ev => set(undo!, ev) |
| 126 | }), |
| 127 | ) |
| 128 | }, |
| 129 | getActions({ row }) { |
| 130 | const { $idx=row.id } = row |
| 131 | const title = "Modify" |
| 132 | return [ |
| 133 | h(GridActionsCellItem as any, { |
| 134 | key: 'edit', |
| 135 | icon: h(Edit), |
| 136 | label: title, |
| 137 | title, |
| 138 | async onClick(ev: MouseEvent) { |
| 139 | ev.stopPropagation() |
| 140 | const res = await formDialog<T>({ |
| 141 | values: row as any, |
| 142 | form: getFormProp(saveOn === 'change' && { |
| 143 | save: false, |
| 144 | onChange(values: T) { |
| 145 | updateRec($idx, values, 'change') |
| 146 | } |
| 147 | } || saveOn && { save: false }), |
| 148 | title: h(Fragment, {}, title, label && ' - ', label), |
| 149 | dialogProps: dialog |
| 150 | }) |
| 151 | if (res) |
| 152 | updateRec($idx, res, ev) |
| 153 | } |
| 154 | }), |
| 155 | h(GridActionsCellItem as any, { |
| 156 | key: 'delete', |
| 157 | icon: h(Delete), |
| 158 | label: "Delete", |
| 159 | showInMenu: reorder, |
| 160 | onClick(ev: any) { |
| 161 | ev.stopPropagation() |
| 162 | set(valueA.filter((_rec, i) => i !== $idx), ev) |
| 163 | }, |
| 164 | }), |
| 165 | reorder && $idx && h(GridActionsCellItem as any, { |
| 166 | key: 'up', |
| 167 | icon: h(ArrowUpward), |
| 168 | label: "Move up", |
| 169 | showInMenu: true, |
| 170 | onClick(ev: any) { |
| 171 | ev.stopPropagation() |
| 172 | set(swap(valueA.slice(), $idx, $idx - 1), ev) |
| 173 | }, |
| 174 | }), |
| 175 | reorder && $idx < rows.length - 1 && h(GridActionsCellItem as any, { |
| 176 | key: 'down', |
| 177 | icon: h(ArrowDownward), |
| 178 | label: "Move down", |
| 179 | showInMenu: true, |
| 180 | onClick(ev: any) { |
| 181 | ev.stopPropagation() |
| 182 | set(swap(valueA.slice(), $idx, $idx + 1), ev) |
| 183 | }, |
| 184 | }), |
| 185 | ].filter(Boolean) |
| 186 | } |
| 187 | } |
| 188 | ] |
| 189 | }) |
| 190 | ) |
| 191 | ) |
| 192 | |
| 193 | function updateRec($idx: number, rec: T, ev?: any) { |
| 194 | set(valueA.map((oldRec, i) => i === $idx ? rec : oldRec), ev) |
| 195 | } |
| 196 | |
| 197 | function set(newValue: typeof valueA, event?: any) { |
| 198 | onChange(!objectK ? newValue : Object.fromEntries((newValue as any).map(({ [objectK]: k, ...v }) => [k, v])), |
| 199 | { was: value, event }) |
| 200 | if (saveOn !== 'change') |
| 201 | setUndo(valueA) |
| 202 | } |
| 203 | |
| 204 | } |
| 205 | |
| 206 | const byType: Dict<{ field?: Partial<FieldDescriptor>, column?: Partial<GridColDef> }> = { |
| 207 | boolean: { |
| 208 | field: { comp: BoolField }, |
| 209 | column: { renderCell: ({ value }) => value && h(Check) }, |
| 210 | }, |
| 211 | dateTime: { |
| 212 | field: { comp: DateTimeField }, |
| 213 | column: { |
| 214 | minWidth: 96, flex: 0.5, |
| 215 | renderCell: ({ value }) => formatTimestamp(value), |
| 216 | } |
| 217 | } |
| 218 | } |