main
ts 218 lines 10.6 KB
Raw
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 }