diff --git a/config/hooks.ts b/config/hooks.ts index 26ffff428a..737253b658 100644 --- a/config/hooks.ts +++ b/config/hooks.ts @@ -32,6 +32,7 @@ export const menus = [ 'useTextSelection', 'useWebSocket', 'useTheme', + 'useRowSpan', ], }, { diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 55c7232b0d..d1d0d82d96 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -76,7 +76,7 @@ import useWebSocket from './useWebSocket'; import useWhyDidYouUpdate from './useWhyDidYouUpdate'; import useMutationObserver from './useMutationObserver'; import useTheme from './useTheme'; - +import useRowSpan from './useRowSpan'; export { useRequest, useControllableValue, @@ -158,4 +158,5 @@ export { useResetState, useMutationObserver, useTheme, + useRowSpan, }; diff --git a/packages/hooks/src/useRowSpan/demo/demo1.tsx b/packages/hooks/src/useRowSpan/demo/demo1.tsx new file mode 100644 index 0000000000..ada222ada7 --- /dev/null +++ b/packages/hooks/src/useRowSpan/demo/demo1.tsx @@ -0,0 +1,251 @@ +import React from 'react'; +import { Table } from 'antd'; +import { useState, useMemo } from 'react'; +import { useRowSpan } from 'ahooks'; +import type { TableProps } from 'antd'; +interface DataType { + key: string; + city: string; + school: string; + name: string; + age: number; + gender: string; +} + +const data: DataType[] = [ + { + city: '四川', + school: '四中', + name: '小红', + age: 18, + gender: '女', + key: '1', + }, + { + city: '四川', + school: '四中', + name: '小明', + age: 18, + gender: '女', + key: '2', + }, + { + city: '四川', + school: '四中', + name: '小李', + age: 19, + gender: '女', + key: '3', + }, + { + city: '四川', + school: '七中', + name: '小王', + age: 20, + gender: '女', + key: '4', + }, + { + city: '四川', + school: '七中', + name: '小张', + age: 20, + gender: '男', + key: '5', + }, + { + city: '四川', + school: '七中', + name: '小赵', + age: 21, + gender: '女', + key: '6', + }, + { + city: '重庆', + school: '七中', + name: '小吴', + age: 24, + gender: '男', + key: '7', + }, + { + city: '重庆', + school: '七中', + name: '小周', + age: 24, + gender: '女', + key: '8', + }, + { + city: '重庆', + school: '三中', + name: '花花', + age: 22, + gender: '女', + key: '9', + }, + { + city: '重庆', + school: '三中', + name: '草草', + age: 22, + gender: '男', + key: '10', + }, + { + city: '重庆', + school: '三中', + name: '莹莹', + age: 23, + gender: '女', + key: '11', + }, + { + city: '重庆', + school: '一中', + name: '明明', + age: 25, + gender: '男', + key: '12', + }, + { + city: '湖北', + school: '六中', + name: '晨晨', + age: 24, + gender: '男', + key: '13', + }, + { + city: '湖北', + school: '六中', + name: '亮亮', + age: 24, + gender: '男', + key: '14', + }, + { + city: '湖北', + school: '六中', + name: '丽丽', + age: 26, + gender: '女', + key: '15', + }, + { + city: '湖北', + school: '一中', + name: '刚刚', + age: 27, + gender: '男', + key: '16', + }, + { + city: '湖北', + school: '一中', + name: '芳芳', + age: 27, + gender: '女', + key: '17', + }, + { + city: '湖北', + school: '二中', + name: '强强', + age: 28, + gender: '男', + key: '18', + }, + { + city: '广东', + school: '中山大学附中', + name: '华华', + age: 25, + gender: '女', + key: '19', + }, + { + city: '广东', + school: '深圳中学', + name: '鹏鹏', + age: 26, + gender: '男', + key: '20', + }, +]; + +const Demo = () => { + const [current, setCurrent] = useState(1); + const [pageSize, setPageSize] = useState(10); + const currentPageData = useMemo(() => { + const startIndex = (current - 1) * pageSize; + const endIndex = startIndex + pageSize; + return data.slice(startIndex, endIndex); + }, [current, pageSize]); + + const getRowSpan = useRowSpan(data, ['city', 'school', 'gender'], currentPageData); + + const columns: TableProps['columns'] = [ + { + title: '城市', + dataIndex: 'city', + key: 'city', + onCell: (record) => ({ + rowSpan: getRowSpan(record, 'city').rowspan, + style: { textAlign: 'center', verticalAlign: 'middle' }, + }), + }, + { + title: '学校', + dataIndex: 'school', + key: 'school', + onCell: (record) => ({ + rowSpan: getRowSpan(record, 'school').rowspan, + style: { textAlign: 'center', verticalAlign: 'middle' }, + }), + }, + { + title: '名字', + dataIndex: 'name', + key: 'name', + }, + { + title: '年龄', + dataIndex: 'age', + key: 'age', + }, + { + title: '性别', + dataIndex: 'gender', + key: 'gender', + onCell: (record) => ({ + rowSpan: getRowSpan(record, 'gender').rowspan, + style: { textAlign: 'center', verticalAlign: 'middle' }, + }), + }, + ]; + + return ( + { + setCurrent(page); + setPageSize(size || 10); + }, + showSizeChanger: true, + showQuickJumper: true, + showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/共 ${total} 条`, + }} + /> + ); +}; + +export default Demo; diff --git a/packages/hooks/src/useRowSpan/demo/demo2.tsx b/packages/hooks/src/useRowSpan/demo/demo2.tsx new file mode 100644 index 0000000000..e0ed05af8c --- /dev/null +++ b/packages/hooks/src/useRowSpan/demo/demo2.tsx @@ -0,0 +1,190 @@ +import React from 'react'; +import { Table } from 'antd'; +import { useState, useMemo } from 'react'; +import { useRowSpan } from 'ahooks'; +import type { TableProps } from 'antd'; + +interface DataType { + key: string; + city: string; + user: { + school: string; + name: string; + age: number; + gender: string; + }; + remark: string; +} + +const data: DataType[] = [ + { + city: '四川', + user: { + school: '四中', + name: '小红', + age: 18, + gender: '女', + }, + key: '1', + remark: '备注1', + }, + { + city: '四川', + user: { + school: '四中', + name: '小明', + age: 18, + gender: '女', + }, + key: '2', + remark: '备注1', + }, + { + city: '四川', + user: { + school: '七中', + name: '小王', + age: 20, + gender: '男', + }, + key: '4', + remark: '备注4', + }, + { + city: '重庆', + user: { + school: '七中', + name: '小吴', + age: 24, + gender: '男', + }, + key: '7', + remark: '备注7', + }, + { + city: '重庆', + user: { + school: '三中', + name: '花花', + age: 22, + gender: '女', + }, + key: '9', + remark: '备注9', + }, + { + city: '湖北', + user: { + school: '六中', + name: '晨晨', + age: 24, + gender: '男', + }, + key: '13', + remark: '备注19', + }, + { + city: '广东', + user: { + school: '中山大学附中', + name: '华华', + age: 25, + gender: '女', + }, + key: '19', + remark: '备注19', + }, +]; + +const Demo2 = () => { + const [current, setCurrent] = useState(1); + const [pageSize, setPageSize] = useState(10); + + const currentPageData = useMemo(() => { + const startIndex = (current - 1) * pageSize; + const endIndex = startIndex + pageSize; + return data.slice(startIndex, endIndex); + }, [current, pageSize]); + + const getRowSpan = useRowSpan( + data, + ['city', 'user.school', 'user.gender', 'remark'], + currentPageData, + ); + + const columns: TableProps['columns'] = [ + { + title: '城市', + dataIndex: 'city', + key: 'city', + onCell: (record) => ({ + rowSpan: getRowSpan(record, 'city').rowspan, + style: { textAlign: 'center', verticalAlign: 'middle' }, + }), + }, + { + title: '学校', + dataIndex: 'user.school', + key: 'school', + onCell: (record) => ({ + rowSpan: getRowSpan(record, 'user.school').rowspan, + style: { textAlign: 'center', verticalAlign: 'middle' }, + }), + render: (text, record) => record.user.school, + }, + { + title: '名字', + dataIndex: 'user.name', + key: 'name', + render: (text, record) => record.user.name, + }, + { + title: '年龄', + dataIndex: 'user.age', + key: 'age', + render: (text, record) => record.user.age, + }, + { + title: '性别', + dataIndex: 'user.gender', + key: 'gender', + onCell: (record) => ({ + rowSpan: getRowSpan(record, 'user.gender').rowspan, + style: { textAlign: 'center', verticalAlign: 'middle' }, + }), + render: (text, record) => record.user.gender, + }, + { + title: '性别', + dataIndex: 'remark', + key: 'remark', + onCell: (record) => ({ + rowSpan: getRowSpan(record, 'remark').rowspan, + style: { textAlign: 'center', verticalAlign: 'middle' }, + }), + }, + ]; + + return ( +
{ + setCurrent(page); + setPageSize(size || 10); + }, + showSizeChanger: true, + showQuickJumper: true, + showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/共 ${total} 条`, + }} + /> + ); +}; + +export default Demo2; diff --git a/packages/hooks/src/useRowSpan/index.en-US.md b/packages/hooks/src/useRowSpan/index.en-US.md new file mode 100644 index 0000000000..296caaeff3 --- /dev/null +++ b/packages/hooks/src/useRowSpan/index.en-US.md @@ -0,0 +1,50 @@ +--- +nav: + path: /hooks +--- + +# useRowSpan + +Generate row span data based on the original data source, suitable for merging rows in tables according to hierarchical levels. + +## Code Demo + +### Basic Usage + + + +## API + +```typescript +const getRowSpan = useRowSpan(allData, ["city", "school", "gender"], currentPageData); +``` +### Params + +| Parameter | Description | Type | Default Value | +| --------------- | ---------------------------------------------------------------------- | ---------- | ------------- | +| allData | The full data set to be merged | `T[]` | - | +| hierarchy | Array of hierarchical fields, specifying which fields to merge rows by | `string[]` | `[]` | +| currentPageData | Optional current page data, used for pagination scenarios | `T[]` | `undefined` | + +### Return Value +- Returns a function `getRowSpan` that retrieves the row span information for a specified record and field. + +### getRowSpan Function Parameters +| Parameter | Description | Type | +| --------- | --------------------------------------------------- | -------- | +| record | The current row data record | `T` | +| field | The field for which to get the row span information | `string` | + +### getRowSpan Function Return Value +| Property | Description | Type | +| -------- | -------------------------- | -------- | +| rowspan | The number of rows to span | `number` | + +### Notes + +- The order of fields in the `hierarchy` array determines the merging priority, with earlier fields having higher priority. + +- If `currentPageData` is provided, row span information will only be calculated based on the current page data. + +- This Hook uses `useMemo`, and the calculation will only be re-run when `allData`, `currentPageData`, or `hierarchy` changes. + diff --git a/packages/hooks/src/useRowSpan/index.tsx b/packages/hooks/src/useRowSpan/index.tsx new file mode 100644 index 0000000000..a668a58c5d --- /dev/null +++ b/packages/hooks/src/useRowSpan/index.tsx @@ -0,0 +1,68 @@ +import { useMemo } from 'react'; +function getNestedValue(record: any, path: string): any { + return path.split('.').reduce((acc, key) => (acc ? acc[key] : undefined), record); +} +export default function useRowSpan>( + data: T[], + hierarchy: string[] = [], + currentPageData?: T[], +) { + const getRowSpan = useMemo(() => { + if (!data || data.length === 0) { + return () => ({ rowspan: 1 }); + } + + const workingData = currentPageData || data; + + const processedData = workingData.map((item, index) => ({ + ...item, + key: item.key || `row-${index}`, + })); + + const spanMap: Record> = {}; + + processedData.forEach((item) => { + spanMap[item.key] = {}; + hierarchy.forEach((field) => { + spanMap[item.key][field] = { rowspan: 1 }; + }); + }); + + hierarchy.forEach((field, level) => { + let spanCount = 1; + let spanStartIndex = 0; + + for (let i = 0; i < processedData.length; i++) { + const shouldMerge = + i > 0 && + hierarchy + .slice(0, level) + .every( + (h) => + getNestedValue(processedData[i], h) === getNestedValue(processedData[i - 1], h), + ) && + getNestedValue(processedData[i], field) === getNestedValue(processedData[i - 1], field); + + console.log(`Checking merge for field ${field} at index ${i}:`, shouldMerge); + + if (shouldMerge) { + spanCount++; + spanMap[processedData[i].key][field] = { rowspan: 0 }; + } + + if (!shouldMerge || i === processedData.length - 1) { + spanMap[processedData[spanStartIndex].key][field] = { rowspan: spanCount }; + spanCount = 1; + spanStartIndex = i; + } + } + }); + + return (record: T, field: string) => { + const recordKey = record.key || `row-${workingData.indexOf(record)}`; + return spanMap[recordKey]?.[field] || { rowspan: 1 }; + }; + }, [data, currentPageData, hierarchy]); + + return getRowSpan; +} diff --git a/packages/hooks/src/useRowSpan/index.zh-CN.md b/packages/hooks/src/useRowSpan/index.zh-CN.md new file mode 100644 index 0000000000..ca4f468677 --- /dev/null +++ b/packages/hooks/src/useRowSpan/index.zh-CN.md @@ -0,0 +1,52 @@ +--- +nav: + path: /hooks +--- + +# useRowSpan + +根据原数据源生成行合并数据,适用于表格中按层级合并行的场景。 + +## 代码演示 + +### 基础用法 + + + +### 数据嵌套 + + +### API + +```typescript +const getRowSpan = useRowSpan(data, ["city", "school", "gender"], currentPageData); +``` + +### Params + +| 参数 | 说明 | 类型 | 默认值 | +| --------------- | ------------------- | ---------- | ----------- | +| allData | 需要合并的全量数据 | `T[]` | - | +| hierarchy | 层级字段数组,用于指定按哪些字段合并行。支持嵌套字段路径(如 `user.school`) | `string[]` | `[]` | +| currentPageData | 当前页面数据,可选,用于分页场景 | `T[]` | `undefined` | + + +### 返回值 +- 返回一个函数 `getRowSpan`,用于获取指定记录和字段的行合并信息。 + +### getRowSpan 函数参数 +| 参数 | 说明 | 类型 | +| ------ | ------------ | -------- | +| record | 当前行数据记录 | `T` | +| field | 需要获取行合并信息的字段 | `string` | + +### getRowSpan 函数返回值 +| 属性 | 说明 | 类型 | +| ------- | ------ | -------- | +| rowspan | 行合并的数量 | `number` | + +### 注意事项 + +- `hierarchy` 数组中字段的顺序决定了合并的优先级,靠前的字段优先级更高。 +- 如果提供了 `currentPageData`,则只会基于当前页面数据计算行合并信息。 +- 该 Hook 使用了 `useMemo`,只有当 `allData`、`currentPageData` 或 `hierarchy` 发生变化时才会重新计算。 diff --git a/packages/hooks/src/useRowSpan/tests/index.spec.ts b/packages/hooks/src/useRowSpan/tests/index.spec.ts new file mode 100644 index 0000000000..655dacf073 --- /dev/null +++ b/packages/hooks/src/useRowSpan/tests/index.spec.ts @@ -0,0 +1,91 @@ +import { renderHook } from '@testing-library/react'; +import { describe, test, expect } from 'vitest'; +import useRowSpan from '../index'; + +describe('useRowSpan', () => { + // 测试基本功能 + test('should return a function when input data is empty', () => { + const { result } = renderHook(() => useRowSpan([], ['key'])); + expect(typeof result.current).toBe('function'); + }); + + // 测试正常情况下的 rowSpan 计算 + test('should calculate rowSpan correctly for same consecutive values', () => { + const data = [ + { id: 1, name: 'A' }, + { id: 2, name: 'A' }, + { id: 3, name: 'B' }, + { id: 4, name: 'A' }, + ]; + + const { result } = renderHook(() => useRowSpan(data, ['name'])); + + // 预期结果:第一行显示rowSpan值,后续相同值的行显示0 + expect(result.current(data[0], 'name')).toEqual({ rowspan: 2 }); + expect(result.current(data[1], 'name')).toEqual({ rowspan: 0 }); + expect(result.current(data[2], 'name')).toEqual({ rowspan: 1 }); + expect(result.current(data[3], 'name')).toEqual({ rowspan: 1 }); + }); + + // 测试所有数据都相同时的情况 + test('should calculate rowSpan correctly when all values are the same', () => { + const data = [ + { id: 1, category: 'X' }, + { id: 2, category: 'X' }, + { id: 3, category: 'X' }, + ]; + + const { result } = renderHook(() => useRowSpan(data, ['category'])); + + expect(result.current(data[0], 'category')).toEqual({ rowspan: 3 }); + expect(result.current(data[1], 'category')).toEqual({ rowspan: 0 }); + expect(result.current(data[2], 'category')).toEqual({ rowspan: 0 }); + }); + + // 测试所有数据都不同情况 + test('should calculate rowSpan correctly when all values are different', () => { + const data = [ + { id: 1, type: 'A' }, + { id: 2, type: 'B' }, + { id: 3, type: 'C' }, + ]; + + const { result } = renderHook(() => useRowSpan(data, ['type'])); + + expect(result.current(data[0], 'type')).toEqual({ rowspan: 1 }); + expect(result.current(data[1], 'type')).toEqual({ rowspan: 1 }); + expect(result.current(data[2], 'type')).toEqual({ rowspan: 1 }); + }); + + // 测试嵌套属性路径 + test('should work with nested property path', () => { + const data = [ + { id: 1, user: { department: 'IT' } }, + { id: 2, user: { department: 'IT' } }, + { id: 3, user: { department: 'HR' } }, + ]; + + const { result } = renderHook(() => useRowSpan(data, ['user.department'])); + + expect(result.current(data[0], 'user.department')).toEqual({ rowspan: 2 }); + expect(result.current(data[1], 'user.department')).toEqual({ rowspan: 0 }); + expect(result.current(data[2], 'user.department')).toEqual({ rowspan: 1 }); + }); + + // 测试包含 undefined 或 null 值的情况 + test('should handle null or undefined values correctly', () => { + const data = [ + { id: 1, value: null }, + { id: 2, value: null }, + { id: 3, value: 'A' }, + { id: 4, value: undefined }, + ]; + + const { result } = renderHook(() => useRowSpan(data, ['value'])); + + expect(result.current(data[0], 'value')).toEqual({ rowspan: 2 }); + expect(result.current(data[1], 'value')).toEqual({ rowspan: 0 }); + expect(result.current(data[2], 'value')).toEqual({ rowspan: 1 }); + expect(result.current(data[3], 'value')).toEqual({ rowspan: 1 }); + }); +});