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-router-dom": "^5.2.0",
"react-select": "^5.2.2", "react-select": "^5.2.2",
"react-virtualized-auto-sizer": "^1.0.7", "react-virtualized-auto-sizer": "^1.0.7",
"react-vtree": "^2.0.4",
"react-window": "^1.8.8", "react-window": "^1.8.8",
"trim-newlines": "^4.0.2" "trim-newlines": "^4.0.2"
}, },
@ -2812,7 +2813,6 @@
"version": "1.8.5", "version": "1.8.5",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz",
"integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==", "integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==",
"dev": true,
"dependencies": { "dependencies": {
"@types/react": "*" "@types/react": "*"
} }
@ -14109,6 +14109,20 @@
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc" "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": { "node_modules/react-window": {
"version": "1.8.8", "version": "1.8.8",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz", "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz",
@ -19653,7 +19667,6 @@
"version": "1.8.5", "version": "1.8.5",
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz",
"integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==", "integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==",
"dev": true,
"requires": { "requires": {
"@types/react": "*" "@types/react": "*"
} }
@ -28192,6 +28205,14 @@
"integrity": "sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==", "integrity": "sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==",
"requires": {} "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": { "react-window": {
"version": "1.8.8", "version": "1.8.8",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz", "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-router-dom": "^5.2.0",
"react-select": "^5.2.2", "react-select": "^5.2.2",
"react-virtualized-auto-sizer": "^1.0.7", "react-virtualized-auto-sizer": "^1.0.7",
"react-vtree": "^2.0.4",
"react-window": "^1.8.8", "react-window": "^1.8.8",
"trim-newlines": "^4.0.2" "trim-newlines": "^4.0.2"
}, },

View File

@ -11,9 +11,9 @@
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
align-items: center; align-items: center;
height: 50px; height: 44px;
margin-right: 15px; margin-right: 15px;
margin-top: 15px; // margin-top: 15px;
vertical-align: middle; vertical-align: middle;
border-bottom: solid 1px rgba(var(--center-channel-color-rgb), 0.08); 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. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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 { IPropertyOption, IPropertyTemplate, Board, BoardGroup } from '../../blocks/board'
import {createBoardView, BoardView} from '../../blocks/boardView' import { createBoardView, BoardView } from '../../blocks/boardView'
import {Card} from '../../blocks/card' import { Card } from '../../blocks/card'
import {Constants, Permission} from '../../constants' import { Constants, Permission } from '../../constants'
import mutator from '../../mutator' import mutator from '../../mutator'
import {Utils} from '../../utils' import { Utils } from '../../utils'
import {useAppDispatch} from '../../store/hooks' import { useAppDispatch } from '../../store/hooks'
import {updateView} from '../../store/views' import { updateView } from '../../store/views'
import {useHasCurrentBoardPermissions} from '../../hooks/permissions' import { useHasCurrentBoardPermissions } from '../../hooks/permissions'
import BoardPermissionGate from '../permissions/boardPermissionGate' import BoardPermissionGate from '../permissions/boardPermissionGate'
@ -22,7 +29,8 @@ import HiddenCardCount from '../../components/hiddenCardCount/hiddenCardCount'
import TableHeaders from './tableHeaders' import TableHeaders from './tableHeaders'
import TableRows from './tableRows' import TableRows from './tableRows'
import TableGroup from './tableGroup' import TableRow from './tableRow'
import TableGroupHeaderRow from './tableGroupHeaderRow'
import CalculationRow from './calculation/calculationRow' import CalculationRow from './calculation/calculationRow'
import {ColumnResizeProvider} from './tableColumnResizeContext' import {ColumnResizeProvider} from './tableColumnResizeContext'
@ -43,15 +51,29 @@ type Props = {
showHiddenCardCountNotification: (show: boolean) => void 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 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 isManualSort = activeView.fields.sortOptions?.length === 0
const canEditBoardProperties = useHasCurrentBoardPermissions([Permission.ManageBoardProperties]) const canEditBoardProperties = useHasCurrentBoardPermissions([Permission.ManageBoardProperties])
const canEditCards = useHasCurrentBoardPermissions([Permission.ManageBoardCards]) const canEditCards = useHasCurrentBoardPermissions([Permission.ManageBoardCards])
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const resizeColumn = useCallback(async (columnId: string, width: number) => { 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) const newWidth = Math.max(Constants.minColumnWidth, width)
if (newWidth !== columnWidths[columnId]) { if (newWidth !== columnWidths[columnId]) {
Utils.log(`Resize of column finished: prev=${columnWidths[columnId]}, new=${newWidth}`) 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) => { const onDropToGroup = useCallback((srcCard: Card, groupID: string, dstCardID: string) => {
Utils.log(`onDropToGroup: ${srcCard.title}`) Utils.log(`onDropToGroup: ${srcCard.title}`)
const {selectedCardIds} = props const { selectedCardIds } = props
const draggedCardIds = Array.from(new Set(selectedCardIds).add(srcCard.id)) const draggedCardIds = Array.from(new Set(selectedCardIds).add(srcCard.id))
const description = draggedCardIds.length > 1 ? `drag ${draggedCardIds.length} cards` : 'drag card' 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) await mutator.changePropertyOptionValue(board.id, board.cardProperties, groupByProperty!, option, text)
}, [board, groupByProperty]) }, [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 ( return (
<div className='Table'> <div className='Table'>
<ColumnResizeProvider <ColumnResizeProvider
@ -188,63 +330,54 @@ const Table = (props: Props): JSX.Element => {
{/* Table rows */} {/* Table rows */}
<div className='table-row-container'> <div className='table-row-container'>
{activeView.fields.groupById && {activeView.fields.groupById &&
visibleGroups.map((group) => { <AutoSizer disableWidth>
return ( {({ height }) => (
<TableGroup <FixedSizeTree
key={group.option.id} height={height}
board={board} treeWalker={treeWalker}
activeView={activeView} itemSize={44}
groupByProperty={groupByProperty} width={'100%'}
group={group} >
readonly={props.readonly || !canEditCards} {Node}
selectedCardIds={props.selectedCardIds} </FixedSizeTree>
cardIdToFocusOnRender={props.cardIdToFocusOnRender} )}
hideGroup={hideGroup} </AutoSizer>
addCard={props.addCard}
showCard={props.showCard}
propertyNameChanged={propertyNameChanged}
onCardClicked={props.onCardClicked}
onDropToGroupHeader={onDropToGroupHeader}
onDropToCard={onDropToCard}
onDropToGroup={onDropToGroup}
/>)
})
} }
{/* No Grouping, Rows, one per card */} {/* No Grouping, Rows, one per card */}
{!activeView.fields.groupById && {!activeView.fields.groupById &&
<TableRows <TableRows
board={board} board={board}
activeView={activeView} activeView={activeView}
cards={cards} cards={cards}
selectedCardIds={props.selectedCardIds} selectedCardIds={props.selectedCardIds}
readonly={props.readonly || !canEditCards} readonly={props.readonly || !canEditCards}
cardIdToFocusOnRender={props.cardIdToFocusOnRender} cardIdToFocusOnRender={props.cardIdToFocusOnRender}
showCard={props.showCard} showCard={props.showCard}
addCard={props.addCard} addCard={props.addCard}
onCardClicked={props.onCardClicked} onCardClicked={props.onCardClicked}
onDrop={onDropToCard} onDrop={onDropToCard}
useVirtualizedList={true} useVirtualizedList={true}
/> />
} }
</div> </div>
{/* Add New row */} {/* Add New row */}
<div className='octo-table-footer'> <div className='octo-table-footer'>
{!props.readonly && !activeView.fields.groupById && {!props.readonly && !activeView.fields.groupById &&
<BoardPermissionGate permissions={[Permission.ManageBoardCards]}> <BoardPermissionGate permissions={[Permission.ManageBoardCards]}>
<div <div
className='octo-table-cell' className='octo-table-cell'
onClick={() => { onClick={() => {
props.addCard('') props.addCard('')
}} }}
> >
<FormattedMessage <FormattedMessage
id='TableComponent.plus-new' id='TableComponent.plus-new'
defaultMessage='+ New' defaultMessage='+ New'
/> />
</div> </div>
</BoardPermissionGate> </BoardPermissionGate>
} }
</div> </div>
@ -258,10 +391,10 @@ const Table = (props: Props): JSX.Element => {
</ColumnResizeProvider> </ColumnResizeProvider>
{hiddenCardsCount > 0 && {hiddenCardsCount > 0 &&
<HiddenCardCount <HiddenCardCount
showHiddenCardNotification={props.showHiddenCardCountNotification} showHiddenCardNotification={props.showHiddenCardCountNotification}
hiddenCardsCount={hiddenCardsCount} hiddenCardsCount={hiddenCardsCount}
/>} />}
</div> </div>
) )
} }

View File

@ -47,6 +47,7 @@ const TableGroup = (props: Props): JSX.Element => {
onDrop={props.onDropToGroupHeader} onDrop={props.onDropToGroupHeader}
key={group.option.id} key={group.option.id}
onDropToGroup={onDropToGroup} onDropToGroup={onDropToGroup}
groupToggle={() => {}}
/> />
{(group.cards.length > 0) && {(group.cards.length > 0) &&
@ -63,8 +64,7 @@ const TableGroup = (props: Props): JSX.Element => {
onDrop={props.onDropToCard} onDrop={props.onDropToCard}
useVirtualizedList={false} useVirtualizedList={false}
/>} />}
</> </>
) )
} }

View File

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

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
/* eslint-disable max-lines */ /* 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 {FormattedMessage, useIntl} from 'react-intl'
import {useDrag, useDrop} from 'react-dnd' import {useDrag, useDrop} from 'react-dnd'
@ -35,6 +35,7 @@ type Props = {
propertyNameChanged: (option: IPropertyOption, text: string) => Promise<void> propertyNameChanged: (option: IPropertyOption, text: string) => Promise<void>
onDrop: (srcOption: IPropertyOption, dstOption?: IPropertyOption) => void onDrop: (srcOption: IPropertyOption, dstOption?: IPropertyOption) => void
onDropToGroup: (srcCard: Card, groupID: string, dstCardID: string) => void onDropToGroup: (srcCard: Card, groupID: string, dstCardID: string) => void
groupToggle: () => void
} }
const TableGroupHeaderRow = (props: Props): JSX.Element => { 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 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 ( return (
<div <div
key={group.option.id + 'header'} key={group.option.id + 'header'}
@ -103,7 +112,7 @@ const TableGroupHeaderRow = (props: Props): JSX.Element => {
<CompassIcon <CompassIcon
icon='menu-right' icon='menu-right'
/>} />}
onClick={() => (props.readonly ? {} : props.hideGroup(group.option.id || 'undefined'))} onClick={toggleGroup}
className={`octo-table-cell__expand ${props.readonly ? 'readonly' : ''}`} className={`octo-table-cell__expand ${props.readonly ? 'readonly' : ''}`}
/> />