背景
最近公司的项目需要做一个表格,用于记录一些配置信息,同时希望用户可以对这些配置进行修改并保存。为此,我需要使用 MUI 来开发一个能满足需求的组件。虽然 MUI 提供了表格组件,但是想要给用户一个不错的体验,还是需要做一些额外开发,并且很多地方需要自己探索才能找到解决方案
绘制一个表格
MUI 提供了一个表格组件 Table,但是我使用的是功能更为强大的 MUI-X 里面提供的 DataGrid 组件。MUI-X 有三个版本,一个免费版和两个付费版。由于预算有限,所以我们使用的是免费版,对于这个需求也是绰绰有余
和其他组件库类似,DataGrid 也需要定义每行的字段和数据,之后我们可以很容易的绘制出一张表格
const rows: any = [
{
name: '参数A',
value1: 100,
value2: 200,
value3: 300,
value4: 400,
min: 0,
max: 1000,
},
...
]
const columns: GridColDef[] = [
{
field: 'name',
headerName: '参数名',
flex: 1
},
{
field: 'value1',
headerName: '配置1',
flex: 1,
},
...
{
field: 'range',
headerName: '取值范围',
flex: 1,
renderCell: ({ row }) => {
const { min, max } = row
return <div>{`${min} - ${max}`}</div>
},
},
]
...
return (
<DataGrid
rows={rows}
columns={columns}
getRowId={(row) => row.name} // 数据没有id属性的话要指定一个属性为id,否则会报错
/>
)
一张最基本的表格就绘制出来了
添加编辑功能
需求中要求用户可以对表单内容进行编辑,而 DataGrid 里面自带了属性可以使单元格可编辑。只需要在 columns 中对应的字段中设置 editable: true即可
const columns: GridColDef[] = [
...
{
field: 'value1',
type: 'number',
headerName: '配置1',
flex: 1,
editable: true, // 设置这个属性使字段可以编辑
},
...
]
修改后的效果如下
可以看到,现在我们可以随意编辑单元格,然后点击其他地方或是按下回车就可以完成编辑。这样用户就可以非常方便的对数据进行修改。如果我们希望在编辑前后进行任何操作,比如调用接口,我们可以定义
onCellEditStart 和 onCellEditStop 属性。比如我们现在希望用户点击其他地方,也就是单元格失焦的时候不退出编辑模式,只有通过回车才能进行保存,我们可以这样修改
<DataGrid
rows={rows}
columns={columns}
getRowId={(row) => row.name}
onCellEditStop={(params, e) => {
// 按回车保存,按ESC退出,其他操作则阻止退出编辑模式
switch (params.reason) {
case GridCellEditStopReasons.enterKeyDown:
updateData()
break
case GridCellEditStopReasons.escapeKeyDown:
break
default:
e.defaultMuiPrevented = true
break
}
}}
/>
修改后的效果如下
现在我们就实现了用户可编辑的功能,基本满足了项目的需求
行编辑模式
上面的例子绘制了一张表格,并且允许用户对数据进行编辑,看起来是不错的,不过有两个地方欠佳
通过上面示例我们可以看到,用户启用编辑模式是通过双击单元格来完成。这个操作虽然非常方便,但是给用户的提示却不明显。用户无法直观地了解到数据可以编辑,也不知道通过双击来编辑
除此之外,现在单元格每编辑一次就会调用一次接口更新数据,这也一定程度上增加了服务器的压力。既然每一行数据都是一个对象,那么我们更希望是用户编辑完一整行的数据再提交,这样比较符合用户的直觉,也可以减小服务器的压力
针对上面两个问题,我决定给每一行增加一些操作按钮,用来提示用户,并且将编辑模式由单元格编辑改为行编辑
增加操作列
这个其实没啥技术含量,就是在 columns 里面加一个对象,增加几个按钮,做一些状态管理,实现几个 onClick 函数就可以了,直接上代码
const columns = [
...
{
field: 'actions',
headerName: '操作',
width: 180,
sortable: false,
renderCell: (params) => {
const id = params.id as string
const { error } = params.row
return (
<Flex className='h-full gap-2' alignCenter>
{editingRows.includes(id) ? (
<>
<Button
variant='outlined'
onClick={() => handleCancelClick(id)}
>
取消
</Button>
<Button
variant='contained'
onClick={() => handleSaveClick(id)}
>
保存
</Button>
</>
) : (
<Button variant='outlined' onClick={() => handleEditClick(id)}>
编辑
</Button>
)}
</Flex>
)
},
},
]
const [editingRows, setEditingRows] = useState<string[]>([])
const handleEditClick = (rowId: string) => {
setEditingRows((rows) => {
return [...rows, rowId]
})
}
const handleCancelClick = (rowId: string) => {
setEditingRows((rows) => {
return rows.filter((key) => key !== rowId)
})
}
const handleSaveClick = (rowId: string) => {
updateData()
setEditingRows((rows) => {
return rows.filter((key) => key !== rowId)
})
}
Flex 组件是我项目中自定义的组件,类似于 antd 里面的 Flex 组件,可以根据自己的组件自行替换。修改后的效果如下
现在我们在表格行添加了按钮,让用户可以感知到如何编辑和保存数据了
启用行编辑
虽然我们添加了编辑保存按钮,但是可以看到我们点击按钮的时候,单元格的状态并没有发生变化。这是因为我们目前的编辑模式还是单元格模式,这也是 DataGrid 默认的编辑模式,所以我们需要启用行编辑模式,并且让单元格的状态随着用户的操作变化
首先在 DataGrid 中设置 editMode 属性
<DataGrid
...
editMode='row'
...
/>
随后我们需要在用户操作的时候控制行的编辑状态。DataGrid 里面提供了 useGridApiRef 这个hook,用于管理表格的状态和数据,所以我们调用这个hook并将返回的ref赋给 DataGrid 组件
const apiRef = useGridApiRef()
...
return (
<DataGrid
ref={apiRef}
...
/>
)
然后我们通过 apiRef.current.startRowEditMode() 和 apiRef.current.stopRowEditMode() 这两个方法就可以控制行的编辑状态了,整理后的代码如下
...
// 记录当前已修改的数据
const [dirtyRows, setDirtyRows] = useState<any>(rows)
// 开始编辑行
const startRowEdit = (rowId: string) => {
updateRowEditMode(rowId)
}
// 结束编辑行
const stopRowEdit = (rowId: string, ignoreChanges?: boolean) => {
updateRowEditMode(rowId, true, ignoreChanges)
}
// 更新行编辑状态
const updateRowEditMode = (
rowId: string,
stop?: boolean,
ignoreChanges?: boolean
) => {
if (stop) {
apiRef.current.stopRowEditMode({
id: rowId,
ignoreModifications: ignoreChanges, // 是否忽略改动,取消时要忽略
})
return
}
apiRef.current.startRowEditMode({
id: rowId,
})
}
// 将该行加入正在编辑的行中
const insertEditRow = (rowId: string) => {
updateEditingRows(rowId, true)
}
// 将该行从正在编辑的行中移除
const removeEditRow = (rowId: string) => {
updateEditingRows(rowId, false)
}
// 更新正在编辑的行
const updateEditingRows = (rowId: string, insert: boolean) => {
setEditingRows((rows) => {
return insert ? [...rows, rowId] : rows.filter((key) => key !== rowId)
})
}
// 开始编辑
const handleEditClick = (rowId: string) => {
startRowEdit(rowId)
insertEditRow(rowId)
}
// 取消编辑,恢复数据
const handleCancelClick = (rowId: string) => {
stopRowEdit(rowId, true)
removeEditRow(rowId)
}
// 保存编辑,更新数据
const handleSaveClick = (rowId: string) => {
stopRowEdit(rowId)
removeEditRow(rowId)
}
return (
<DataGrid
...
rows={dirtyRows}
...
/>
)
修改后的效果如下
现在我们已经实现了行编辑的效果,不过除了点击编辑外,现在用户任意点击单元格都会让这一行进入编辑态,这并不是我们希望看到的。我们需要让这个行为可控,所以我们需要监控行的编辑状态,并且对异常情况做出控制。与单元格编辑类似,
DataGrid 提供了 onRowEditStart 和 onRowEditStop 属性来对行编辑状态进行控制。所以我们需要移除先前定义的 onCellEditStop 属性,改为上面提到的两个
// 开始行编辑
const handleRowEditStart = (params: GridRowEditStartParams, e: MuiEvent) => {
// 只有双击触发行编辑
if (params.reason === GridRowEditStartReasons.cellDoubleClick) {
insertEditRow(params.id as string)
return
}
// 禁用所有其他触发方式
e.defaultMuiPrevented = true
}
// 结束行编辑
const handleRowEditStop = (params: GridRowEditStopParams, e: MuiEvent) => {
// 只有ESC退出行编辑
if (params.reason === GridRowEditStopReasons.escapeKeyDown) {
stopRowEdit(params.id as string, true)
removeEditRow(params.id as string)
return
}
// 禁用其他所有退出方式
e.defaultMuiPrevented = true
}
...
return (
<DataGrid
...
onRowEditStart={handleRowEditStart}
onRowEditStop={handleRowEditStop}
// 移除onCellEditStop
/>
)
修改后的效果如下
可以看到,现在只有双击可以进入编辑模式,并且只有ESC可以退出编辑模式。到目前为止,我们已经实现了通过行编辑模式更新数据的功能
数据校验
在调用接口前对数据进行校验必不可少,在这个表格中我们有一列叫做【取值范围】,就是指定用户输入值的范围,超出这个范围我们应该对用户进行提示并且阻止其更新数据。虽然 DataGrid 提供了 preProcessEditCellProps来在用户编辑时进行校验,但是我们需要进行额外的处理
首先我们在 columns 里面的对象中加入 preProcessEditCellProps 属性
const columns = [
...
{
field: 'value1',
...
preProcessEditCellProps: (params) =>
handleBeforeEditCellStop(params, 'value1'),
}
...
]
// 编辑确认前的操作
const handleBeforeEditCellStop = (
params: GridPreProcessEditCellProps,
key: string
) => {
const { value } = params.props
// 更新表格数据
setDirtyRows((rows: any) => {
return rows.map((row: any) => {
let hasError = false
if (row.name !== params.id) {
return row
}
// 判断当前行的所有输入是否合法,决定是否可以保存
;['value1', 'value2', 'value3', 'value4'].map((field) => {
const value = field === key ? params.props : row[field]
hasError = hasError || value < row.min || value > row.max
})
return {
...row,
[key]: value,
error: hasError, // 设置错误属性
}
})
})
return { ...params.props, [key]: value }
}
随后加入错误数据的样式
const columns = [
...
{
field: 'value1',
...
cellClassName: (params) => getCellClassName(params),
...
}
...
]
// 校验数据
const getCellClassName = (params: GridCellParams<any, any>) => {
const { min, max } = params.row
const hasError = params.value < min || params.value > max
return hasError ? 'cell-error' : ''
}
.cell-error
border: 1px solid red !important
最后判断当行内有非法数据时不能进行保存
const columns = [
...
{
field: 'actions',
...
renderCell: (params) => {
const { error } = params.row
return (
<Flex className='h-full gap-2' alignCenter>
...
<Button
variant='contained'
disabled={error}
onClick={() => handleSaveClick(id)}
>
保存
</Button>
...
</Flex>
)
},
},
]
最终的效果如下
总结
通过以上步骤,我们通过 MUI-X DataGrid 组件实现了一个可编辑的表格。虽然大部分都是用的组件自带的属性来实现,但是由于 MUI-X 文档给出的示例比较少,不是很完善,有些功能需要通过翻阅API文档或是询问AI才能了解。当然实现这样一个表格的方式可能有很多种,这里只是给出一种比较符合我的项目的方法,有些地方可能也不是很完善。欢迎各位小伙伴提供其他的方案
完整代码
EditableTable.tsx
import Button from '@mui/material/Button';
import { Flex } from '@/components'
import {
DataGrid,
GridCellParams,
GridColDef,
GridPreProcessEditCellProps,
GridRowEditStartParams,
GridRowEditStartReasons,
GridRowEditStopParams,
GridRowEditStopReasons,
MuiEvent,
useGridApiRef,
} from '@mui/x-data-grid'
import { zhCN } from '@mui/x-data-grid/locales'
import { FC, useState } from 'react'
import './style.sass'
const EditableTable: FC = () => {
const rows: any = [
{
name: '参数A',
value1: 100,
value2: 200,
value3: 300,
value4: 400,
min: 0,
max: 1000,
},
{
name: '参数B',
value1: 50,
value2: 170,
value3: 350,
value4: 904,
min: 0,
max: 2000,
},
{
name: '参数C',
value1: 5,
value2: 37,
value3: 60,
value4: 90,
min: 0,
max: 100,
},
]
const columns: GridColDef[] = [
{
field: 'name',
headerName: '参数名',
flex: 1,
},
{
field: 'value1',
type: 'number',
headerName: '配置1',
cellClassName: (params) => getCellClassName(params),
flex: 1,
editable: true,
preProcessEditCellProps: (params) =>
handleBeforeEditCellStop(params, 'value1'),
},
{
field: 'value2',
type: 'number',
headerName: '配置2',
cellClassName: (params) => getCellClassName(params),
flex: 1,
editable: true,
preProcessEditCellProps: (params) =>
handleBeforeEditCellStop(params, 'value2'),
},
{
field: 'value3',
type: 'number',
headerName: '配置3',
cellClassName: (params) => getCellClassName(params),
flex: 1,
editable: true,
preProcessEditCellProps: (params) =>
handleBeforeEditCellStop(params, 'value3'),
},
{
field: 'value4',
type: 'number',
headerName: '配置4',
cellClassName: (params) => getCellClassName(params),
flex: 1,
editable: true,
preProcessEditCellProps: (params) =>
handleBeforeEditCellStop(params, 'value4'),
},
{
field: 'range',
headerName: '取值范围',
flex: 1,
renderCell: ({ row }) => {
const { min, max } = row
return <div>{`${min} - ${max}`}</div>
},
},
{
field: 'actions',
headerName: '操作',
width: 180,
sortable: false,
renderCell: (params) => {
const id = params.id as string
const { error } = params.row
return (
<Flex className='h-full gap-2' alignCenter>
{editingRows.includes(id) ? (
<>
<Button
variant='outlined'
onClick={() => handleCancelClick(id)}
>
取消
</Button>
<Button
variant='contained'
disabled={error}
onClick={() => handleSaveClick(id)}
>
保存
</Button>
</>
) : (
<Button variant='outlined' onClick={() => handleEditClick(id)}>
编辑
</Button>
)}
</Flex>
)
},
},
]
const [originRows, setOriginRows] = useState<any>(rows)
const [dirtyRows, setDirtyRows] = useState<any>(rows)
const [editingRows, setEditingRows] = useState<string[]>([])
// MUI-X DataGrid提供的ref,可以对表格进行操作
const apiRef = useGridApiRef()
/**
* 根据取值范围判断值是否合法
*/
const getCellClassName = (params: GridCellParams<any, any>) => {
const { min, max } = params.row
const hasError = params.value < min || params.value > max
return hasError ? 'cell-error' : ''
}
/**
* 处理单元格停止编辑前的动作
*/
const handleBeforeEditCellStop = (
params: GridPreProcessEditCellProps,
key: string
) => {
const { value } = params.props
// 更新表格数据,否则无法判断输入是否合法
setDirtyRows((rows: any) => {
return rows.map((row: any) => {
let hasError = false
if (row.name !== params.id) {
return row
}
// 判断当前行的所有输入是否合法,决定是否可以保存
;['value1', 'value2', 'value3', 'value4'].forEach((field) => {
const value = field === key ? params.props.value : row[field]
hasError = hasError || value < row.min || value > row.max
})
return {
...row,
[key]: value,
error: hasError,
}
})
})
return { ...params.props, [key]: value }
}
/**
* 开始编辑行
*/
const startRowEdit = (rowId: string) => {
updateRowEditMode(rowId)
}
/**
* 结束编辑行
*/
const stopRowEdit = (rowId: string, ignoreChanges?: boolean) => {
updateRowEditMode(rowId, true, ignoreChanges)
}
/**
* 将当前行插入正在编辑的行中
*/
const insertEditRow = (rowId: string) => {
updateEditingRows(rowId, true)
}
/**
* 将当前行从正在编辑的行中移除
*/
const removeEditRow = (rowId: string) => {
updateEditingRows(rowId, false)
}
/**
* 更新行编辑状态
*/
const updateRowEditMode = (
rowId: string,
stop?: boolean,
ignoreChanges?: boolean
) => {
if (stop) {
apiRef.current.stopRowEditMode({
id: rowId,
ignoreModifications: ignoreChanges,
})
return
}
apiRef.current.startRowEditMode({
id: rowId,
})
}
/**
* 更新正在编辑的行
*/
const updateEditingRows = (rowId: string, insert: boolean) => {
setEditingRows((rows) => {
return insert ? [...rows, rowId] : rows.filter((key) => key !== rowId)
})
}
/**
* 处理行开始编辑
*/
const handleRowEditStart = (params: GridRowEditStartParams, e: MuiEvent) => {
if (params.reason === GridRowEditStartReasons.cellDoubleClick) {
insertEditRow(params.id as string)
return
}
e.defaultMuiPrevented = true
}
/**
* 处理行结束编辑
*/
const handleRowEditStop = (params: GridRowEditStopParams, e: MuiEvent) => {
if (params.reason === GridRowEditStopReasons.escapeKeyDown) {
stopRowEdit(params.id as string, true)
removeEditRow(params.id as string)
return
}
e.defaultMuiPrevented = true
}
/**
* 处理编辑点击
*/
const handleEditClick = (rowId: string) => {
startRowEdit(rowId)
insertEditRow(rowId)
}
/**
* 处理取消点击
*/
const handleCancelClick = (rowId: string) => {
stopRowEdit(rowId, true)
removeEditRow(rowId)
setDirtyRows([...originRows])
}
/**
* 处理保存点击
*/
const handleSaveClick = (rowId: string) => {
stopRowEdit(rowId)
removeEditRow(rowId)
setOriginRows([...dirtyRows])
}
return (
<Flex className='gap-4' column>
<Container>
<DataGrid
apiRef={apiRef}
rows={dirtyRows}
columns={columns}
editMode='row'
getRowId={(row) => row.name}
pageSizeOptions={[]}
localeText={zhCN.components.MuiDataGrid.defaultProps.localeText}
disableColumnMenu
disableRowSelectionOnClick
onRowEditStart={handleRowEditStart}
onRowEditStop={handleRowEditStop}
/>
</Container>
</Flex>
)
}
export default EditableTable
style.sass
.cell-error
border: 1px solid red !important