Skip to content

feat: support rowspan expanded #1278

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 27 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/demo/expandedRowSpan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: expandedRowSpan
nav:
title: Demo
path: /demo
---

<code src="../examples/expandedRowSpan.tsx"></code>
8 changes: 8 additions & 0 deletions docs/demo/expandedSticky.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: expandedSticky
nav:
title: Demo
path: /demo
---

<code src="../examples/expandedSticky.tsx"></code>
51 changes: 51 additions & 0 deletions docs/examples/expandedRowSpan.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react';
import Table from 'rc-table';
import '../../assets/index.less';
import type { ColumnsType } from '@/interface';

const columns: ColumnsType = [
{
title: '手机号',
dataIndex: 'a',
colSpan: 2,
width: 100,
onCell: (_, index) => {
const props: React.TdHTMLAttributes<HTMLTableCellElement> = {};
if (index === 0) props.rowSpan = 1;
if (index === 1) props.rowSpan = 4;
if (index === 2) props.rowSpan = 0;
if (index === 3) props.rowSpan = 0;
if (index === 4) props.rowSpan = 0;
if (index === 5) props.rowSpan = undefined;
return props;
},
},
{ title: '电话', dataIndex: 'b', colSpan: 0, width: 100 },
Table.EXPAND_COLUMN,
{ title: 'Name', dataIndex: 'c', width: 100 },
{ title: 'Address', dataIndex: 'd', width: 200 },
];

const data = [
{ a: '12313132132', b: '0571-43243256', c: '小二', d: '文零西路', e: 'Male', key: 'z' },
{ a: '13812340987', b: '0571-12345678', c: '张三', d: '文一西路', e: 'Male', key: 'a' },
{ a: '13812340987', b: '0571-12345678', c: '张夫人', d: '文一西路', e: 'Female', key: 'b' },
{ a: '13812340987', b: '0571-099877', c: '李四', d: '文二西路', e: 'Male', key: 'c' },
{ a: '13812340987', b: '0571-099877', c: '李四', d: '文二西路', e: 'Male', key: 'd' },
{ a: '1381200008888', b: '0571-099877', c: '王五', d: '文二西路', e: 'Male', key: 'e' },
];

const Demo = () => (
<div>
<h2>expanded & rowSpan</h2>
<Table<Record<string, any>>
rowKey="key"
columns={columns}
data={data}
expandable={{ expandedRowRender: record => <p style={{ margin: 0 }}>{record.key}</p> }}
className="table"
/>
</div>
);

export default Demo;
57 changes: 57 additions & 0 deletions docs/examples/expandedSticky.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React, { useState } from 'react';
import type { ColumnType } from 'rc-table';
import Table from 'rc-table';
import '../../assets/index.less';

const Demo = () => {
const [expandedRowKeys, setExpandedRowKeys] = useState<readonly React.Key[]>([]);

const columns: ColumnType<Record<string, any>>[] = [
// { title: '分割', dataIndex: 'ca' },
{
title: '手机号',
dataIndex: 'a',
width: 100,
fixed: 'left',
onCell: (_, index) => {
const props: React.TdHTMLAttributes<HTMLTableCellElement> = {};
if (index === 0) props.rowSpan = 1;
if (index === 1) props.rowSpan = 2;
if (index === 2) props.rowSpan = 0;
return props;
},
},
Table.EXPAND_COLUMN,
{ title: 'Name', dataIndex: 'c' },
{ title: 'Address', fixed: 'right', dataIndex: 'd', width: 200 },
];

return (
<div
style={{
height: 10000,
}}
>
<h2>expanded & sticky</h2>
<Table<Record<string, any>>
rowKey="key"
sticky
scroll={{ x: 800 }}
columns={columns}
data={[
{ key: 'a', a: '12313132132', c: '小二', d: '文零西路' },
{ key: 'b', a: '13812340987', c: '张三', d: '文一西路' },
{ key: 'c', a: '13812340987', c: '张夫', d: '文二西路' },
]}
expandable={{
expandedRowKeys,
onExpandedRowsChange: keys => setExpandedRowKeys(keys),
expandedRowRender: record => <p style={{ margin: 0 }}>{record.key}</p>,
}}
className="table"
/>
</div>
);
};

export default Demo;
76 changes: 63 additions & 13 deletions src/Body/BodyRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import type { ColumnType, CustomizeComponent } from '../interface';
import ExpandedRow from './ExpandedRow';
import { computedExpandedClassName } from '../utils/expandUtil';
import { TableProps } from '..';
import type { TableProps } from '..';

export interface BodyRowProps<RecordType> {
record: RecordType;
Expand All @@ -22,6 +22,7 @@
scopeCellComponent: CustomizeComponent;
indent?: number;
rowKey: React.Key;
rowKeys: React.Key[];
}

// ==================================================================================
Expand All @@ -33,6 +34,7 @@
colIndex: number,
indent: number,
index: number,
rowKeys: React.Key[],
) {
const {
record,
Expand All @@ -46,6 +48,8 @@
expanded,
hasNestChildren,
onTriggerExpand,
expandable,
expandedKeys,
} = rowInfo;

const key = columnsKey[colIndex];
Expand Down Expand Up @@ -74,6 +78,21 @@
let additionalCellProps: React.TdHTMLAttributes<HTMLElement>;
if (column.onCell) {
additionalCellProps = column.onCell(record, index);
const { rowSpan } = additionalCellProps;

// For expandable row with rowSpan,
// We should increase the rowSpan if the row is expanded
if (expandable) {
let currentRowSpan = rowSpan;

Check warning on line 86 in src/Body/BodyRow.tsx

View check run for this annotation

Codecov / codecov/patch

src/Body/BodyRow.tsx#L86

Added line #L86 was not covered by tests

for (let i = index; i < index + rowSpan; i += 1) {
const rowKey = rowKeys[i];
if (expandedKeys.has(rowKey)) {
currentRowSpan += 1;
}
}
additionalCellProps.rowSpan = currentRowSpan;
}

Check warning on line 95 in src/Body/BodyRow.tsx

View check run for this annotation

Codecov / codecov/patch

src/Body/BodyRow.tsx#L88-L95

Added lines #L88 - L95 were not covered by tests
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

实现行合并与行展开的协同处理

这段代码实现了行展开时动态调整 rowSpan 的核心逻辑,但缺少测试覆盖。

    const { rowSpan } = additionalCellProps;

    // For expandable row with rowSpan,
    // We should increase the rowSpan if the row is expanded
-    if (expandable) {
+    if (expandable && rowSpan > 0) {
      let currentRowSpan = rowSpan;

      for (let i = index; i < index + rowSpan; i += 1) {
+       if (i >= rowKeys.length) break;
        const rowKey = rowKeys[i];
        if (expandedKeys.has(rowKey)) {
          currentRowSpan += 1;
        }
      }
      additionalCellProps.rowSpan = currentRowSpan;
    }

代码存在以下隐患:

  1. 没有检查 rowSpan 是否为有效值 (>0)
  2. 没有检查 i 是否超出 rowKeys 数组边界
  3. 建议为新增的复杂逻辑添加单元测试

🏁 Script executed:

#!/bin/bash
# 检查是否有相关测试文件涵盖 BodyRow 组件中的 rowSpan 功能

echo "查找与 BodyRow 相关的测试文件..."
fd -e test.tsx -e test.ts -e spec.tsx -e spec.ts | grep -i "bodyrow\|body.*row"

echo "查找测试文件中是否包含 rowSpan 相关测试..."
rg -i "rowspan.*test|test.*rowspan" --type ts --type tsx

Length of output: 288


🏁 Script executed:

#!/bin/bash
echo "查找包含 BodyRow 的测试文件..."
rg -l "BodyRow" -g "*.test.ts" -g "*.test.tsx" -g "*.spec.ts" -g "*.spec.tsx"

echo "在测试文件中查找 rowSpan 相关测试..."
rg -n "rowSpan" -g "*.test.ts" -g "*.test.tsx" -g "*.spec.ts" -g "*.spec.tsx"

Length of output: 715


校验 rowSpan 有效性并防止数组越界

src/Body/BodyRow.tsx(约 81–95 行)中,这段逻辑负责在展开行时动态调整 rowSpan,但存在以下隐患需要修复并补充测试:

  1. 未过滤 rowSpan 为 0 或负数的情况
  2. 在循环中可能读取超出 rowKeys 长度的索引
  3. 缺少针对该逻辑的单元测试

建议修改如下:

-   if (expandable) {
+   // 仅对可展开且 rowSpan > 0 的单元格生效
+   if (expandable && rowSpan > 0) {
      let currentRowSpan = rowSpan;

      for (let i = index; i < index + rowSpan; i += 1) {
+       // 防止越界访问
+       if (i >= rowKeys.length) break;
        const rowKey = rowKeys[i];
        if (expandedKeys.has(rowKey)) {
          currentRowSpan += 1;
        }
      }
      additionalCellProps.rowSpan = currentRowSpan;
    }

· 请在 tests/ 下新增 BodyRow.spec.tsx,覆盖以下场景:

  • rowSpan 为 0、1、>1
  • 不同 expandedKeys 集合下的展开行为
  • 边界情况(index + rowSpan 超出 rowKeys.length
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { rowSpan } = additionalCellProps;
// For expandable row with rowSpan,
// We should increase the rowSpan if the row is expanded
if (expandable) {
let currentRowSpan = rowSpan;
for (let i = index; i < index + rowSpan; i += 1) {
const rowKey = rowKeys[i];
if (expandedKeys.has(rowKey)) {
currentRowSpan += 1;
}
}
additionalCellProps.rowSpan = currentRowSpan;
}
const { rowSpan } = additionalCellProps;
// For expandable row with rowSpan,
// We should increase the rowSpan if the row is expanded
// 仅对可展开且 rowSpan > 0 的单元格生效
if (expandable && rowSpan > 0) {
let currentRowSpan = rowSpan;
for (let i = index; i < index + rowSpan; i += 1) {
// 防止越界访问
if (i >= rowKeys.length) break;
const rowKey = rowKeys[i];
if (expandedKeys.has(rowKey)) {
currentRowSpan += 1;
}
}
additionalCellProps.rowSpan = currentRowSpan;
}
🧰 Tools
🪛 GitHub Check: codecov/patch

[warning] 86-86: src/Body/BodyRow.tsx#L86
Added line #L86 was not covered by tests


[warning] 88-95: src/Body/BodyRow.tsx#L88-L95
Added lines #L88 - L95 were not covered by tests

🤖 Prompt for AI Agents
In src/Body/BodyRow.tsx around lines 81 to 95, the code adjusts rowSpan when
rows are expanded but lacks validation and boundary checks. Fix this by first
verifying that rowSpan is a positive number greater than zero before proceeding.
Then, in the loop, ensure the index i does not exceed the length of the rowKeys
array to prevent out-of-bounds access. Additionally, create a new test file
tests/BodyRow.spec.tsx to add unit tests covering cases where rowSpan is 0, 1,
or greater than 1, different expandedKeys sets, and boundary conditions where
index plus rowSpan exceeds rowKeys length.

}

return {
Expand All @@ -84,9 +103,31 @@
};
}

// ==================================================================================
// == getCellProps ==
// ==================================================================================
const getOffsetData = (
columnsData: {
column: ColumnType<any>;
cell: { additionalCellProps: React.TdHTMLAttributes<HTMLElement> };
}[],
) => {
let offsetWidth = 0;
let offsetColumn = 0;
let isRowSpanEnd = false;
columnsData.forEach(item => {
if (!isRowSpanEnd) {
const { column, cell } = item;
if (cell.additionalCellProps.rowSpan !== undefined) {
offsetColumn += 1;
if (typeof column.width === 'number') {
offsetWidth = offsetWidth + (column.width ?? 0);
}

Check warning on line 122 in src/Body/BodyRow.tsx

View check run for this annotation

Codecov / codecov/patch

src/Body/BodyRow.tsx#L121-L122

Added lines #L121 - L122 were not covered by tests
} else {
isRowSpanEnd = true;
}
}
});
return { offsetWidth, offsetColumn };
};

function BodyRow<RecordType extends { children?: readonly RecordType[] }>(
props: BodyRowProps<RecordType>,
) {
Expand All @@ -107,9 +148,11 @@
rowComponent: RowComponent,
cellComponent,
scopeCellComponent,
rowKeys,
} = props;

const rowInfo = useRowInfo(record, rowKey, index, indent);

const {
prefixCls,
flattenColumns,
Expand All @@ -134,6 +177,17 @@
// 此时如果 level > 1 则说明是 expandedRow, 一样需要附加 computedExpandedRowClassName
const expandedClsName = computedExpandedClassName(expandedRowClassName, record, index, indent);

const { columnsData, offsetData } = React.useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-shadow
const columnsData = flattenColumns.map((column: ColumnType<RecordType>, colIndex) => {
const cell = getCellProps(rowInfo, column, colIndex, indent, index, rowKeys);
return { column, cell };
});
// eslint-disable-next-line @typescript-eslint/no-shadow
const offsetData = getOffsetData(columnsData);
return { columnsData, offsetData };
}, [flattenColumns, indent, index, rowInfo, rowKeys]);

// ======================== Base tr row ========================
const baseRowNode = (
<RowComponent
Expand All @@ -155,16 +209,11 @@
...styles.row,
}}
>
{flattenColumns.map((column: ColumnType<RecordType>, colIndex) => {
{columnsData.map(item => {
const { column, cell } = item;
const { render, dataIndex, className: columnClassName } = column;

const { key, fixedInfo, appendCellNode, additionalCellProps } = getCellProps(
rowInfo,
column,
colIndex,
indent,
index,
);
const { key, fixedInfo, appendCellNode, additionalCellProps } = cell;

return (
<Cell<RecordType>
Expand Down Expand Up @@ -207,7 +256,8 @@
prefixCls={prefixCls}
component={RowComponent}
cellComponent={cellComponent}
colSpan={flattenColumns.length}
offsetWidth={offsetData.offsetWidth}
colSpan={flattenColumns.length - offsetData.offsetColumn}
isEmpty={false}
Comment on lines +259 to 261
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

确保 colSpan 不会为负值

当计算展开行的 colSpan 时,需要确保结果不会为负值:

<ExpandedRow
  expanded={expanded}
  className={cls(
    `${prefixCls}-expanded-row`,
    `${prefixCls}-expanded-row-level-${indent + 1}`,
    expandedClsName,
  )}
  prefixCls={prefixCls}
  component={RowComponent}
  cellComponent={cellComponent}
  offsetWidth={offsetData.offsetWidth}
- colSpan={flattenColumns.length - offsetData.offsetColumn}
+ colSpan={Math.max(1, flattenColumns.length - offsetData.offsetColumn)}
  isEmpty={false}
>

offsetData.offsetColumn 大于或等于 flattenColumns.length 时,计算结果可能为零或负值,这将导致意外的渲染结果。

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
offsetWidth={offsetData.offsetWidth}
colSpan={flattenColumns.length - offsetData.offsetColumn}
isEmpty={false}
<ExpandedRow
expanded={expanded}
className={cls(
`${prefixCls}-expanded-row`,
`${prefixCls}-expanded-row-level-${indent + 1}`,
expandedClsName,
)}
prefixCls={prefixCls}
component={RowComponent}
cellComponent={cellComponent}
offsetWidth={offsetData.offsetWidth}
colSpan={Math.max(
1,
flattenColumns.length - offsetData.offsetColumn
)}
isEmpty={false}
>
🤖 Prompt for AI Agents
In src/Body/BodyRow.tsx around lines 259 to 261, the calculation of colSpan as
flattenColumns.length minus offsetData.offsetColumn can result in zero or
negative values if offsetData.offsetColumn is greater than or equal to
flattenColumns.length. To fix this, add a check to ensure colSpan is never less
than 1 by using a conditional or Math.max to set a minimum value of 1 for
colSpan.

>
{expandContent}
Expand Down
4 changes: 3 additions & 1 deletion src/Body/ExpandedRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface ExpandedRowProps {
children: React.ReactNode;
colSpan: number;
isEmpty: boolean;
offsetWidth?: number;
}

function ExpandedRow(props: ExpandedRowProps) {
Expand All @@ -30,6 +31,7 @@ function ExpandedRow(props: ExpandedRowProps) {
expanded,
colSpan,
isEmpty,
offsetWidth = 0,
} = props;

const { scrollbarSize, fixHeader, fixColumn, componentWidth, horizonScroll } = useContext(
Expand All @@ -44,7 +46,7 @@ function ExpandedRow(props: ExpandedRowProps) {
contentNode = (
<div
style={{
width: componentWidth - (fixHeader && !isEmpty ? scrollbarSize : 0),
width: componentWidth - offsetWidth - (fixHeader && !isEmpty ? scrollbarSize : 0),
position: 'sticky',
left: 0,
overflow: 'hidden',
Expand Down
19 changes: 12 additions & 7 deletions src/Body/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,14 @@ function Body<RecordType>(props: BodyProps<RecordType>) {
const { body: bodyCls = {} } = classNames || {};
const { body: bodyStyles = {} } = styles || {};

const flattenData: { record: RecordType; indent: number; index: number }[] =
useFlattenRecords<RecordType>(data, childrenColumnName, expandedKeys, getRowKey);
const flattenData = useFlattenRecords<RecordType>(
data,
childrenColumnName,
expandedKeys,
getRowKey,
);

const rowKeys = React.useMemo(() => flattenData.map(item => item.rowKey), [flattenData]);

// =================== Performance ====================
const perfRef = React.useRef<PerfRecord>({
Expand All @@ -66,16 +72,15 @@ function Body<RecordType>(props: BodyProps<RecordType>) {
let rows: React.ReactNode;
if (data.length) {
rows = flattenData.map((item, idx) => {
const { record, indent, index: renderIndex } = item;

const key = getRowKey(record, idx);
const { record, indent, index: renderIndex, rowKey } = item;

return (
<BodyRow
classNames={bodyCls}
styles={bodyStyles}
key={key}
rowKey={key}
key={rowKey}
rowKey={rowKey}
rowKeys={rowKeys}
record={record}
index={idx}
renderIndex={renderIndex}
Expand Down
6 changes: 5 additions & 1 deletion src/Panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ export interface TitleProps {
}

function Panel({ className, style, children }: TitleProps) {
return <div className={className} style={style}>{children}</div>;
return (
<div className={className} style={style}>
{children}
</div>
);
}

export default Panel;
2 changes: 2 additions & 0 deletions src/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,8 @@ function Table<RecordType extends DefaultRecordType>(
mergedChildrenColumnName,

rowHoverable,
classNames,
styles,
],
);

Expand Down
1 change: 1 addition & 0 deletions src/VirtualTable/VirtualCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function VirtualCell<RecordType = any>(props: VirtualCellProps<RecordType>) {
colIndex,
indent,
index,
[],
);

const { style: cellStyle, colSpan = 1, rowSpan = 1 } = additionalCellProps;
Expand Down
2 changes: 1 addition & 1 deletion src/context/TableContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
TriggerEventHandler,
} from '../interface';
import type { FixedInfo } from '../utils/fixUtil';
import { TableProps } from '../Table';
import type { TableProps } from '../Table';

const { makeImmutable, responseImmutable, useImmutableMark } = createImmutable();
export { makeImmutable, responseImmutable, useImmutableMark };
Expand Down
Loading