1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-12-21 13:38:56 +02:00

virtual list for grouped table

This commit is contained in:
Benjamin Cooke 2023-03-10 15:45:16 -05:00
parent 19007c486a
commit 77d7e5f6bc
7 changed files with 243 additions and 73 deletions

View File

@ -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",

View File

@ -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"
},

View File

@ -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);

View File

@ -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<HTMLDivElement>, card: Card) => {
props.onCardClicked(e, card)
}, [props.onCardClicked])
const Node = ({data: {node, isCard, isLeaf}, style, toggle, isOpen}: FixedSizeNodeComponentProps<TreeData>) => {
if (isCard) {
const card = node as Card;
return (
<div
style={style}
>
<TableRow
key={card.id + card.updateAt}
board={board}
columnWidths={activeView.fields.columnWidths}
isManualSort={activeView.fields.sortOptions.length === 0}
groupById={activeView.fields.groupById}
visiblePropertyIds={activeView.fields.visiblePropertyIds}
collapsedOptionIds={activeView.fields.collapsedOptionIds}
card={card}
addCard={props.addCard}
isSelected={props.selectedCardIds.includes(card.id)}
focusOnMount={props.cardIdToFocusOnRender === card.id}
isLastCard={isLeaf}
onClick={onClickRow}
showCard={props.showCard}
readonly={props.readonly}
onDrop={onDropToCard}
/>
</div>
)
}
const group = node as BoardGroup;
return (
<div
style={style}
>
<TableGroupHeaderRow
group={group}
board={board}
activeView={activeView}
groupByProperty={groupByProperty}
hideGroup={hideGroup}
addCard={props.addCard}
readonly={props.readonly}
propertyNameChanged={propertyNameChanged}
onDrop={onDropToGroupHeader}
key={group.option.id}
onDropToGroup={onDropToGroup}
groupToggle={toggle}
/>
</div>
)
}
const treeWalker = useCallback(
function* treeWalker(
refresh: boolean,
): Generator<TreeData | string | symbol, void, boolean> {
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 (
<div className='Table'>
<ColumnResizeProvider
@ -188,63 +330,54 @@ const Table = (props: Props): JSX.Element => {
{/* Table rows */}
<div className='table-row-container'>
{activeView.fields.groupById &&
visibleGroups.map((group) => {
return (
<TableGroup
key={group.option.id}
board={board}
activeView={activeView}
groupByProperty={groupByProperty}
group={group}
readonly={props.readonly || !canEditCards}
selectedCardIds={props.selectedCardIds}
cardIdToFocusOnRender={props.cardIdToFocusOnRender}
hideGroup={hideGroup}
addCard={props.addCard}
showCard={props.showCard}
propertyNameChanged={propertyNameChanged}
onCardClicked={props.onCardClicked}
onDropToGroupHeader={onDropToGroupHeader}
onDropToCard={onDropToCard}
onDropToGroup={onDropToGroup}
/>)
})
<AutoSizer disableWidth>
{({ height }) => (
<FixedSizeTree
height={height}
treeWalker={treeWalker}
itemSize={44}
width={'100%'}
>
{Node}
</FixedSizeTree>
)}
</AutoSizer>
}
{/* No Grouping, Rows, one per card */}
{!activeView.fields.groupById &&
<TableRows
board={board}
activeView={activeView}
cards={cards}
selectedCardIds={props.selectedCardIds}
readonly={props.readonly || !canEditCards}
cardIdToFocusOnRender={props.cardIdToFocusOnRender}
showCard={props.showCard}
addCard={props.addCard}
onCardClicked={props.onCardClicked}
onDrop={onDropToCard}
useVirtualizedList={true}
/>
<TableRows
board={board}
activeView={activeView}
cards={cards}
selectedCardIds={props.selectedCardIds}
readonly={props.readonly || !canEditCards}
cardIdToFocusOnRender={props.cardIdToFocusOnRender}
showCard={props.showCard}
addCard={props.addCard}
onCardClicked={props.onCardClicked}
onDrop={onDropToCard}
useVirtualizedList={true}
/>
}
</div>
{/* Add New row */}
<div className='octo-table-footer'>
{!props.readonly && !activeView.fields.groupById &&
<BoardPermissionGate permissions={[Permission.ManageBoardCards]}>
<div
className='octo-table-cell'
onClick={() => {
props.addCard('')
}}
>
<FormattedMessage
id='TableComponent.plus-new'
defaultMessage='+ New'
/>
</div>
</BoardPermissionGate>
<BoardPermissionGate permissions={[Permission.ManageBoardCards]}>
<div
className='octo-table-cell'
onClick={() => {
props.addCard('')
}}
>
<FormattedMessage
id='TableComponent.plus-new'
defaultMessage='+ New'
/>
</div>
</BoardPermissionGate>
}
</div>
@ -258,10 +391,10 @@ const Table = (props: Props): JSX.Element => {
</ColumnResizeProvider>
{hiddenCardsCount > 0 &&
<HiddenCardCount
showHiddenCardNotification={props.showHiddenCardCountNotification}
hiddenCardsCount={hiddenCardsCount}
/>}
<HiddenCardCount
showHiddenCardNotification={props.showHiddenCardCountNotification}
hiddenCardsCount={hiddenCardsCount}
/>}
</div>
)
}

View File

@ -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}
/>}
</>
</>
)
}

View File

@ -72,6 +72,7 @@ test('should match snapshot, no groups', async () => {
options: [{id: 'property1', value: 'Property 1', color: ''}],
}}
onDropToGroup={jest.fn()}
groupToggle={jest.fn()}
/>
</Wrapper>,
)
@ -91,6 +92,7 @@ test('should match snapshot with Group', async () => {
propertyNameChanged={jest.fn()}
onDrop={jest.fn()}
onDropToGroup={jest.fn()}
groupToggle={jest.fn()}
/>
</Wrapper>,
)
@ -110,6 +112,7 @@ test('should match snapshot on read only', async () => {
propertyNameChanged={jest.fn()}
onDrop={jest.fn()}
onDropToGroup={jest.fn()}
groupToggle={jest.fn()}
/>
</Wrapper>,
)
@ -134,6 +137,7 @@ test('should match snapshot, hide group', async () => {
propertyNameChanged={jest.fn()}
onDrop={jest.fn()}
onDropToGroup={jest.fn()}
groupToggle={jest.fn()}
/>
</Wrapper>,
)
@ -163,6 +167,7 @@ test('should match snapshot, add new', async () => {
propertyNameChanged={jest.fn()}
onDrop={jest.fn()}
onDropToGroup={jest.fn()}
groupToggle={jest.fn()}
/>
</Wrapper>,
)
@ -190,6 +195,7 @@ test('should match snapshot, edit title', async () => {
propertyNameChanged={jest.fn()}
onDrop={jest.fn()}
onDropToGroup={jest.fn()}
groupToggle={jest.fn()}
/>
</Wrapper>,
)

View File

@ -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<void>
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 (
<div
key={group.option.id + 'header'}
@ -103,7 +112,7 @@ const TableGroupHeaderRow = (props: Props): JSX.Element => {
<CompassIcon
icon='menu-right'
/>}
onClick={() => (props.readonly ? {} : props.hideGroup(group.option.id || 'undefined'))}
onClick={toggleGroup}
className={`octo-table-cell__expand ${props.readonly ? 'readonly' : ''}`}
/>