From 77d7e5f6bc47a3b34cfbc569aee0b8edd1523b50 Mon Sep 17 00:00:00 2001 From: Benjamin Cooke Date: Fri, 10 Mar 2023 15:45:16 -0500 Subject: [PATCH] virtual list for grouped table --- webapp/package-lock.json | 25 +- webapp/package.json | 1 + webapp/src/components/table/table.scss | 4 +- webapp/src/components/table/table.tsx | 263 +++++++++++++----- webapp/src/components/table/tableGroup.tsx | 4 +- .../table/tableGroupHeaderRow.test.tsx | 6 + .../components/table/tableGroupHeaderRow.tsx | 13 +- 7 files changed, 243 insertions(+), 73 deletions(-) diff --git a/webapp/package-lock.json b/webapp/package-lock.json index e7d0d95b0..385d8a79e 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -49,6 +49,7 @@ "react-router-dom": "^5.2.0", "react-select": "^5.2.2", "react-virtualized-auto-sizer": "^1.0.7", + "react-vtree": "^2.0.4", "react-window": "^1.8.8", "trim-newlines": "^4.0.2" }, @@ -2812,7 +2813,6 @@ "version": "1.8.5", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz", "integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==", - "dev": true, "dependencies": { "@types/react": "*" } @@ -14109,6 +14109,20 @@ "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc" } }, + "node_modules/react-vtree": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-vtree/-/react-vtree-2.0.4.tgz", + "integrity": "sha512-UOld0VqyAZrryF06K753X4bcEVN6/wW831exvVlMZeZAVHk9KXnlHs4rpqDAeoiBgUwJqoW/rtn0hwsokRRxPA==", + "dependencies": { + "@babel/runtime": "^7.11.0" + }, + "peerDependencies": { + "@types/react-window": "^1.8.2", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-window": "^1.8.5" + } + }, "node_modules/react-window": { "version": "1.8.8", "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz", @@ -19653,7 +19667,6 @@ "version": "1.8.5", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz", "integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==", - "dev": true, "requires": { "@types/react": "*" } @@ -28192,6 +28205,14 @@ "integrity": "sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==", "requires": {} }, + "react-vtree": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-vtree/-/react-vtree-2.0.4.tgz", + "integrity": "sha512-UOld0VqyAZrryF06K753X4bcEVN6/wW831exvVlMZeZAVHk9KXnlHs4rpqDAeoiBgUwJqoW/rtn0hwsokRRxPA==", + "requires": { + "@babel/runtime": "^7.11.0" + } + }, "react-window": { "version": "1.8.8", "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz", diff --git a/webapp/package.json b/webapp/package.json index 4b0568bd7..befe469f0 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -66,6 +66,7 @@ "react-router-dom": "^5.2.0", "react-select": "^5.2.2", "react-virtualized-auto-sizer": "^1.0.7", + "react-vtree": "^2.0.4", "react-window": "^1.8.8", "trim-newlines": "^4.0.2" }, diff --git a/webapp/src/components/table/table.scss b/webapp/src/components/table/table.scss index 30b67fa1b..aec364e2b 100644 --- a/webapp/src/components/table/table.scss +++ b/webapp/src/components/table/table.scss @@ -11,9 +11,9 @@ display: flex; flex-shrink: 0; align-items: center; - height: 50px; + height: 44px; margin-right: 15px; - margin-top: 15px; + // margin-top: 15px; vertical-align: middle; border-bottom: solid 1px rgba(var(--center-channel-color-rgb), 0.08); diff --git a/webapp/src/components/table/table.tsx b/webapp/src/components/table/table.tsx index 335aef447..5eff42e00 100644 --- a/webapp/src/components/table/table.tsx +++ b/webapp/src/components/table/table.tsx @@ -1,18 +1,25 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback} from 'react' +import React, { useCallback } from 'react' -import {FormattedMessage} from 'react-intl' +import { FormattedMessage } from 'react-intl' +import { + FixedSizeTree, + FixedSizeNodeData, + FixedSizeNodeComponentProps, +} from 'react-vtree'; +import { FixedSizeList, ListChildComponentProps } from 'react-window' +import AutoSizer from 'react-virtualized-auto-sizer' -import {IPropertyOption, IPropertyTemplate, Board, BoardGroup} from '../../blocks/board' -import {createBoardView, BoardView} from '../../blocks/boardView' -import {Card} from '../../blocks/card' -import {Constants, Permission} from '../../constants' +import { IPropertyOption, IPropertyTemplate, Board, BoardGroup } from '../../blocks/board' +import { createBoardView, BoardView } from '../../blocks/boardView' +import { Card } from '../../blocks/card' +import { Constants, Permission } from '../../constants' import mutator from '../../mutator' -import {Utils} from '../../utils' -import {useAppDispatch} from '../../store/hooks' -import {updateView} from '../../store/views' -import {useHasCurrentBoardPermissions} from '../../hooks/permissions' +import { Utils } from '../../utils' +import { useAppDispatch } from '../../store/hooks' +import { updateView } from '../../store/views' +import { useHasCurrentBoardPermissions } from '../../hooks/permissions' import BoardPermissionGate from '../permissions/boardPermissionGate' @@ -22,7 +29,8 @@ import HiddenCardCount from '../../components/hiddenCardCount/hiddenCardCount' import TableHeaders from './tableHeaders' import TableRows from './tableRows' -import TableGroup from './tableGroup' +import TableRow from './tableRow' +import TableGroupHeaderRow from './tableGroupHeaderRow' import CalculationRow from './calculation/calculationRow' import {ColumnResizeProvider} from './tableColumnResizeContext' @@ -43,15 +51,29 @@ type Props = { showHiddenCardCountNotification: (show: boolean) => void } +type TreeData = FixedSizeNodeData & +{ + isCard: boolean; + isLeaf: boolean; + node: BoardGroup | Card; +}; + +type StackElement = { + nestingLevel: number; + node: Card | BoardGroup; + isLastCard: boolean; + groupId: string; +}; + const Table = (props: Props): JSX.Element => { - const {board, cards, activeView, visibleGroups, groupByProperty, views, hiddenCardsCount} = props + const { board, cards, activeView, visibleGroups, groupByProperty, views, hiddenCardsCount } = props const isManualSort = activeView.fields.sortOptions?.length === 0 const canEditBoardProperties = useHasCurrentBoardPermissions([Permission.ManageBoardProperties]) const canEditCards = useHasCurrentBoardPermissions([Permission.ManageBoardCards]) const dispatch = useAppDispatch() const resizeColumn = useCallback(async (columnId: string, width: number) => { - const columnWidths = {...activeView.fields.columnWidths} + const columnWidths = { ...activeView.fields.columnWidths } const newWidth = Math.max(Constants.minColumnWidth, width) if (newWidth !== columnWidths[columnId]) { Utils.log(`Resize of column finished: prev=${columnWidths[columnId]}, new=${newWidth}`) @@ -108,7 +130,7 @@ const Table = (props: Props): JSX.Element => { const onDropToGroup = useCallback((srcCard: Card, groupID: string, dstCardID: string) => { Utils.log(`onDropToGroup: ${srcCard.title}`) - const {selectedCardIds} = props + const { selectedCardIds } = props const draggedCardIds = Array.from(new Set(selectedCardIds).add(srcCard.id)) const description = draggedCardIds.length > 1 ? `drag ${draggedCardIds.length} cards` : 'drag card' @@ -170,6 +192,126 @@ const Table = (props: Props): JSX.Element => { await mutator.changePropertyOptionValue(board.id, board.cardProperties, groupByProperty!, option, text) }, [board, groupByProperty]) + const onClickRow = useCallback((e: React.MouseEvent, card: Card) => { + props.onCardClicked(e, card) + }, [props.onCardClicked]) + + const Node = ({data: {node, isCard, isLeaf}, style, toggle, isOpen}: FixedSizeNodeComponentProps) => { + if (isCard) { + const card = node as Card; + + return ( +
+ +
+ + ) + } + const group = node as BoardGroup; + return ( +
+ +
+ ) + } + + const treeWalker = useCallback( + function* treeWalker( + refresh: boolean, + ): Generator { + const stack: StackElement[] = []; + + for (let i = 0; i < visibleGroups.length; i++) { + stack.push({ + nestingLevel: 0, + node: visibleGroups[i], + groupId: visibleGroups[i].option.id, + isLastCard: false, + }); + } + + while (stack.length !== 0) { + const {node, nestingLevel, isLastCard, groupId} = stack.pop()!; + let isOpened = null + let group = null + let card = null + const isVisible = activeView.fields.collapsedOptionIds?.indexOf(groupId) === -1 + + if (nestingLevel === 0) { + group = node as BoardGroup + const id = group.option.id; + isOpened = yield refresh ? + { + id, + isLeaf: group.cards.length === 0, + isOpenByDefault: isVisible, + node, + isCard: false, + } + : id; + } else { + card = node as Card + const id = card.id; + isOpened = yield refresh + ? { + id: card.id+card.updateAt, + isCard: true, + isOpenByDefault: isVisible, + node, + isLeaf: isLastCard, + } + : id; + } + + if (group && group.cards.length !== 0 && isOpened) { + for (let i = group.cards.length - 1; i >= 0; i--) { + stack.push({ + nestingLevel: nestingLevel + 1, + node: group.cards[i], + groupId: group.option.id, + isLastCard: i === (group.cards.length - 1) + }); + } + } + } + } + , [visibleGroups, activeView.fields.collapsedOptionIds]) + + return (
{ {/* Table rows */}
{activeView.fields.groupById && - visibleGroups.map((group) => { - return ( - ) - }) + + {({ height }) => ( + + {Node} + + )} + } {/* No Grouping, Rows, one per card */} {!activeView.fields.groupById && - + }
{/* Add New row */}
{!props.readonly && !activeView.fields.groupById && - -
{ - props.addCard('') - }} - > - -
-
+ +
{ + props.addCard('') + }} + > + +
+
}
@@ -258,10 +391,10 @@ const Table = (props: Props): JSX.Element => {
{hiddenCardsCount > 0 && - } + }
) } diff --git a/webapp/src/components/table/tableGroup.tsx b/webapp/src/components/table/tableGroup.tsx index 2635b438a..62e4802fd 100644 --- a/webapp/src/components/table/tableGroup.tsx +++ b/webapp/src/components/table/tableGroup.tsx @@ -47,6 +47,7 @@ const TableGroup = (props: Props): JSX.Element => { onDrop={props.onDropToGroupHeader} key={group.option.id} onDropToGroup={onDropToGroup} + groupToggle={() => {}} /> {(group.cards.length > 0) && @@ -63,8 +64,7 @@ const TableGroup = (props: Props): JSX.Element => { onDrop={props.onDropToCard} useVirtualizedList={false} />} - - + ) } diff --git a/webapp/src/components/table/tableGroupHeaderRow.test.tsx b/webapp/src/components/table/tableGroupHeaderRow.test.tsx index 4ad2de563..348b6d8f9 100644 --- a/webapp/src/components/table/tableGroupHeaderRow.test.tsx +++ b/webapp/src/components/table/tableGroupHeaderRow.test.tsx @@ -72,6 +72,7 @@ test('should match snapshot, no groups', async () => { options: [{id: 'property1', value: 'Property 1', color: ''}], }} onDropToGroup={jest.fn()} + groupToggle={jest.fn()} /> , ) @@ -91,6 +92,7 @@ test('should match snapshot with Group', async () => { propertyNameChanged={jest.fn()} onDrop={jest.fn()} onDropToGroup={jest.fn()} + groupToggle={jest.fn()} /> , ) @@ -110,6 +112,7 @@ test('should match snapshot on read only', async () => { propertyNameChanged={jest.fn()} onDrop={jest.fn()} onDropToGroup={jest.fn()} + groupToggle={jest.fn()} /> , ) @@ -134,6 +137,7 @@ test('should match snapshot, hide group', async () => { propertyNameChanged={jest.fn()} onDrop={jest.fn()} onDropToGroup={jest.fn()} + groupToggle={jest.fn()} /> , ) @@ -163,6 +167,7 @@ test('should match snapshot, add new', async () => { propertyNameChanged={jest.fn()} onDrop={jest.fn()} onDropToGroup={jest.fn()} + groupToggle={jest.fn()} /> , ) @@ -190,6 +195,7 @@ test('should match snapshot, edit title', async () => { propertyNameChanged={jest.fn()} onDrop={jest.fn()} onDropToGroup={jest.fn()} + groupToggle={jest.fn()} /> , ) diff --git a/webapp/src/components/table/tableGroupHeaderRow.tsx b/webapp/src/components/table/tableGroupHeaderRow.tsx index 2ea95b513..55588a611 100644 --- a/webapp/src/components/table/tableGroupHeaderRow.tsx +++ b/webapp/src/components/table/tableGroupHeaderRow.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. /* eslint-disable max-lines */ -import React, {useState, useEffect, useRef} from 'react' +import React, {useState, useEffect, useRef, useCallback} from 'react' import {FormattedMessage, useIntl} from 'react-intl' import {useDrag, useDrop} from 'react-dnd' @@ -35,6 +35,7 @@ type Props = { propertyNameChanged: (option: IPropertyOption, text: string) => Promise onDrop: (srcOption: IPropertyOption, dstOption?: IPropertyOption) => void onDropToGroup: (srcCard: Card, groupID: string, dstCardID: string) => void + groupToggle: () => void } const TableGroupHeaderRow = (props: Props): JSX.Element => { @@ -86,6 +87,14 @@ const TableGroupHeaderRow = (props: Props): JSX.Element => { const canEditOption = groupByProperty?.type !== 'person' && group.option.id + const toggleGroup = useCallback(() => { + if (props.readonly) { + return + } + props.hideGroup(group.option.id || 'undefined') + props.groupToggle() + }, [props.readonly, group.option.id, props.groupToggle, props.hideGroup]) + return (
{ } - onClick={() => (props.readonly ? {} : props.hideGroup(group.option.id || 'undefined'))} + onClick={toggleGroup} className={`octo-table-cell__expand ${props.readonly ? 'readonly' : ''}`} />