diff --git a/packages/amis-core/__tests__/store/table.test.ts b/packages/amis-core/__tests__/store/table.test.ts new file mode 100644 index 000000000..8da90eb20 --- /dev/null +++ b/packages/amis-core/__tests__/store/table.test.ts @@ -0,0 +1,113 @@ +import {TableStore, SELECTED_STATUS} from '../../src/store/table'; + +import type {IRow, ITableStore} from '../../src/store/table'; + +let table: ITableStore; +let firstLevel: IRow; +let secondLevel: IRow; +let secondLevel2: IRow; +let thirdLevel: IRow; +let thirdLevel2: IRow; +let thirdLevel3: IRow; + +beforeEach(() => { + table = TableStore.create({id: 'mock-id', storeType: 'table'}); + table.initRows([ + { + name: '手机维修', + children: [ + { + name: '苹果', + children: [ + { + name: 'iphone系列' + } + ] + }, + { + name: '华为', + children: [ + { + name: 'mate系列' + }, + { + name: 'p系列' + } + ] + } + ] + } + ]); + + firstLevel = table.rows[0]; + secondLevel = table.rows[0].children[0]; + secondLevel2 = table.rows[0].children[1]; + thirdLevel = table.rows[0].children[0].children[0]; + thirdLevel2 = table.rows[0].children[1].children[0]; + thirdLevel3 = table.rows[0].children[1].children[1]; +}); + +describe('Row', () => { + it('通过parent快速拿到父节点', () => { + expect(secondLevel.parent).toEqual(firstLevel); + }); + + it('通过table快速拿到根节点', () => { + expect(thirdLevel.table).toEqual(table); + }); +}); + +describe('TableStore', () => { + it('选中父节点,所有子节点自动选中', () => { + firstLevel.toggle(true); + + const selectedRows = table.selectedRows.map(row => row.data.name); + + expect(selectedRows).toEqual( + expect.arrayContaining([ + '苹果', + 'iphone系列', + '华为', + 'mate系列', + 'p系列' + ]) + ); + }); + + it('选中所有子节点,所有父节点自动选中', () => { + thirdLevel.toggle(true); + thirdLevel2.toggle(true); + thirdLevel3.toggle(true); + + const selectedRows = table.selectedRows.map(row => row.data.name); + + expect(selectedRows).toEqual( + expect.arrayContaining(['苹果', '华为', '手机维修']) + ); + }); + + it('默认情况下,父节点是未选中状态,选中一个子节点切换到部分选中状态,选中所有2个子节点切换到全部选中状态', () => { + expect(firstLevel.childrenSelected()).toBe(SELECTED_STATUS.NONE); + expect(firstLevel.partial).toBe(false); + + secondLevel.toggle(true); + + expect(firstLevel.childrenSelected()).toBe(SELECTED_STATUS.PARTIAL); + expect(firstLevel.partial).toBe(true); + + secondLevel2.toggle(true); + + expect(firstLevel.childrenSelected()).toBe(SELECTED_STATUS.ALL); + expect(firstLevel.partial).toBe(false); + }); + + it('多个子节点只选中一个,祖先节点不会被选中,但会被标记为部分选中状态', () => { + thirdLevel2.toggle(true); + + expect(secondLevel2.checked).toBe(false); + expect(secondLevel2.partial).toBe(true); + + expect(firstLevel.checked).toBe(false); + expect(firstLevel.partial).toBe(true); + }); +}); diff --git a/packages/amis-core/src/store/table.ts b/packages/amis-core/src/store/table.ts index db9172bf3..89123a711 100644 --- a/packages/amis-core/src/store/table.ts +++ b/packages/amis-core/src/store/table.ts @@ -78,6 +78,12 @@ function initChildren( }); } +export enum SELECTED_STATUS { + ALL, + PARTIAL, + NONE +} + export const Column = types .model('Column', { label: types.optional(types.frozen(), undefined), @@ -182,6 +188,14 @@ export const Row = types lazyRender: false }) .views(self => ({ + get parent() { + return getParent(self, 2); + }, + + get table() { + return getParent(self, self.depth * 2); + }, + get expandable(): boolean { let table: any; return !!( @@ -193,6 +207,34 @@ export const Row = types ); }, + childrenSelected() { + const {children, table} = self as IRow; + + const selectedLength = children.filter((child: IRow) => + (table as ITableStore).isSelected(child) + ).length; + + if (!selectedLength) { + return SELECTED_STATUS.NONE; + } + + if (selectedLength === children.length) { + return SELECTED_STATUS.ALL; + } + + return SELECTED_STATUS.PARTIAL; + }, + + get partial(): boolean { + const childrenSelected = + this.childrenSelected() === SELECTED_STATUS.PARTIAL; + const childrenPartial = (self as IRow).children.some( + (child: IRow) => child.partial + ); + + return childrenSelected || childrenPartial; + }, + get checked(): boolean { return (getParent(self, self.depth * 2) as ITableStore).isSelected( self as IRow @@ -334,10 +376,12 @@ export const Row = types })) .actions(self => ({ toggle(checked: boolean) { - (getParent(self, self.depth * 2) as ITableStore).toggle( - self as IRow, - checked - ); + const table = self.table as ITableStore; + const row = self as IRow; + + table.toggle(row, checked); + table.toggleAncestors(row); + table.toggleDescendants(row, checked); }, toggleExpanded() { @@ -1552,6 +1596,33 @@ export const TableStore = iRendererStore } } + function toggleAncestors(row: IRow) { + const parent = row.parent as IRow; + + if (!parent.depth) { + return; + } + + const selectedStatus = parent.childrenSelected(); + + toggle(parent, selectedStatus === SELECTED_STATUS.ALL); + + toggleAncestors(parent); + } + + function toggleDescendants(row: IRow, checked: boolean) { + const {children} = row; + + if (!children?.length) { + return; + } + + children.forEach((child: IRow) => { + toggle(child, checked); + toggleDescendants(child, checked); + }); + } + function getToggleShiftRows(row: IRow) { // 如果是同一个或非 multiple 模式下就和不用 shift 一样 if (!lastCheckedRow || row === lastCheckedRow || !self.multiple) { @@ -1816,6 +1887,8 @@ export const TableStore = iRendererStore toggleAll, getSelectedRows, toggle, + toggleAncestors, + toggleDescendants, toggleShift, getToggleShiftRows, toggleExpandAll, diff --git a/packages/amis/__tests__/renderers/Table/Cell.test.tsx b/packages/amis/__tests__/renderers/Table/Cell.test.tsx new file mode 100644 index 000000000..4037343fb --- /dev/null +++ b/packages/amis/__tests__/renderers/Table/Cell.test.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { + fireEvent, + render, + waitFor, + screen, + within +} from '@testing-library/react'; +import {render as amisRender} from '../../../src'; +import {makeEnv} from '../../helper'; + +const items = [ + { + name: '手机维修', + children: [ + { + name: '苹果', + children: [ + { + name: 'iphone系列' + } + ] + }, + { + name: '华为', + children: [ + { + name: 'mate系列' + }, + { + name: 'p系列' + } + ] + } + ] + } +]; + +const getExpandBtnByText = (text: string) => { + const tdElement = screen.getByText(text).parentElement as HTMLElement; + + return tdElement.querySelector('.cxd-Table-expandBtn2') as HTMLAnchorElement; +}; + +const getCheckMeBtnByText = (text: string) => { + const trElement = screen.getByText(text).parentElement! + .parentElement as HTMLElement; + + return within(trElement!).getByRole('checkbox'); +}; + +const renderTable = () => + render( + amisRender( + { + type: 'crud', + headerToolbar: ['bulkActions'], + bulkActions: [ + { + type: 'button', + label: '批量操作' + } + ], + footable: { + expand: 'first' + }, + data: { + items + }, + columns: [ + { + name: 'name', + label: 'name' + } + ] + }, + {}, + makeEnv({}) + ) + ); + +describe('层级选择', () => { + it('选择根节点,所有后代节点都会自动选中', async () => { + renderTable(); + const checkMeFirst = getCheckMeBtnByText('手机维修'); + + expect(checkMeFirst).toBeInTheDocument(); + + // 选中一级节点 + fireEvent.click(checkMeFirst); + + // 一级节点选中 + await waitFor(() => { + expect(checkMeFirst).toBeChecked(); + }); + + const checkMeSecond1 = getCheckMeBtnByText('苹果'); + const checkMeSecond2 = getCheckMeBtnByText('华为'); + + // 二级节点选中 + expect(checkMeSecond1).toBeChecked(); + expect(checkMeSecond2).toBeChecked(); + + const expandSecond1 = getExpandBtnByText('苹果'); + + expect(expandSecond1).toBeInTheDocument(); + + fireEvent.click(expandSecond1); + + const checkMeThird1 = getCheckMeBtnByText('iphone系列'); + + // 三级节点选中 + await waitFor(() => { + expect(checkMeThird1).toBeInTheDocument(); + }); + }); + + it('选择所有子节点,所有祖先点选中', async () => { + renderTable(); + const expandSecond1 = getExpandBtnByText('苹果'); + + // 展开三级节点 + fireEvent.click(expandSecond1); + const checkMeThird1 = getCheckMeBtnByText('iphone系列'); + + await waitFor(() => { + expect(checkMeThird1).toBeInTheDocument(); + }); + + fireEvent.click(checkMeThird1); + + const checkMeSecond1 = getCheckMeBtnByText('苹果'); + const checkMeFirst = getCheckMeBtnByText('手机维修'); + + await waitFor(() => { + expect(checkMeSecond1).toBeChecked(); + expect(checkMeSecond1.parentElement!.classList).toContain( + 'cxd-Checkbox--full' + ); + }); + + // 根节点下包含苹果和华为两个节点 + // 华为节点是未选中,所以根节点应该显示部分选中的样式 + expect(checkMeFirst).toBeChecked(); + expect(checkMeFirst.parentElement!.classList).not.toContain( + 'cxd-Checkbox--full' + ); + }); + + it('选择部分子节点,所有祖先点选中,但选中状态是部分选中', async () => { + renderTable(); + const expandSecond2 = getExpandBtnByText('华为'); + + // 展开三级节点 + fireEvent.click(expandSecond2); + const checkMeThird1 = getCheckMeBtnByText('mate系列'); + + await waitFor(() => { + expect(checkMeThird1).toBeInTheDocument(); + }); + + fireEvent.click(checkMeThird1); + + const checkMeSecond2 = getCheckMeBtnByText('华为'); + const checkMeFirst = getCheckMeBtnByText('手机维修'); + + await waitFor(() => { + expect(checkMeSecond2).toBeChecked(); + expect(checkMeSecond2.parentElement!.classList).not.toContain( + 'cxd-Checkbox--full' + ); + }); + + expect(checkMeFirst).toBeChecked(); + expect(checkMeFirst.parentElement!.classList).not.toContain( + 'cxd-Checkbox--full' + ); + }); +}); diff --git a/packages/amis/src/renderers/Table/Cell.tsx b/packages/amis/src/renderers/Table/Cell.tsx index 9da470971..bf325630b 100644 --- a/packages/amis/src/renderers/Table/Cell.tsx +++ b/packages/amis/src/renderers/Table/Cell.tsx @@ -82,7 +82,8 @@ export default function Cell({ diff --git a/packages/amis/src/renderers/Table/TableRow.tsx b/packages/amis/src/renderers/Table/TableRow.tsx index 28cbd27f4..7bebe5e49 100644 --- a/packages/amis/src/renderers/Table/TableRow.tsx +++ b/packages/amis/src/renderers/Table/TableRow.tsx @@ -55,6 +55,7 @@ export class TableRow extends React.PureComponent< newIndex: number; isHover: boolean; checked: boolean; + partial?: boolean; modified: boolean; moved: boolean; depth: number; @@ -358,6 +359,7 @@ export default observer((props: TableRowProps) => { id={item.id} newIndex={item.newIndex} isHover={item.isHover} + partial={item.partial} checked={item.checked} modified={item.modified} moved={item.moved}