focalboard/webapp/src/components/viewHeader.tsx

473 lines
21 KiB
TypeScript
Raw Normal View History

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
2020-11-13 03:48:59 +01:00
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {Archiver} from '../archiver'
import {BlockIcons} from '../blockIcons'
import {IPropertyTemplate} from '../blocks/board'
2020-11-13 03:48:59 +01:00
import {ISortOption, MutableBoardView} from '../blocks/boardView'
import {MutableCard} from '../blocks/card'
import {CardFilter} from '../cardFilter'
import ViewMenu from '../components/viewMenu'
2020-11-13 03:48:59 +01:00
import {Constants} from '../constants'
import {CsvExporter} from '../csvExporter'
import mutator from '../mutator'
2020-11-13 03:48:59 +01:00
import {BoardTree} from '../viewModel/boardTree'
import Button from '../widgets/buttons/button'
import ButtonWithMenu from '../widgets/buttons/buttonWithMenu'
import IconButton from '../widgets/buttons/iconButton'
2020-12-14 22:06:41 +01:00
import CardIcon from '../widgets/icons/card'
import CheckIcon from '../widgets/icons/check'
2020-11-13 03:48:59 +01:00
import DeleteIcon from '../widgets/icons/delete'
import DropdownIcon from '../widgets/icons/dropdown'
import OptionsIcon from '../widgets/icons/options'
import SortDownIcon from '../widgets/icons/sortDown'
2020-11-13 03:48:59 +01:00
import SortUpIcon from '../widgets/icons/sortUp'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import {Editable} from './editable'
import FilterComponent from './filterComponent'
2020-10-25 22:29:20 +01:00
import './viewHeader.scss'
2020-11-04 20:24:06 +01:00
type Props = {
boardTree: BoardTree
showView: (id: string) => void
2020-11-12 23:06:02 +01:00
setSearchText: (text?: string) => void
2020-11-10 20:23:08 +01:00
addCard: () => void
addCardFromTemplate: (cardTemplateId: string) => void
2020-11-10 20:23:08 +01:00
addCardTemplate: () => void
2020-11-11 18:21:16 +01:00
editCardTemplate: (cardTemplateId: string) => void
withGroupBy?: boolean
intl: IntlShape
}
type State = {
isSearching: boolean
showFilter: boolean
}
class ViewHeader extends React.Component<Props, State> {
private searchFieldRef = React.createRef<Editable>()
shouldComponentUpdate(): boolean {
return true
}
constructor(props: Props) {
super(props)
this.state = {isSearching: Boolean(this.props.boardTree.getSearchText()), showFilter: false}
}
componentDidUpdate(prevPros: Props, prevState: State): void {
if (this.state.isSearching && !prevState.isSearching) {
2020-11-17 19:53:46 +01:00
this.searchFieldRef.current?.focus()
}
}
2020-12-02 18:28:52 +01:00
private showFilterDialog = () => {
this.setState({showFilter: true})
}
2020-12-02 18:28:52 +01:00
private hideFilterDialog = () => {
this.setState({showFilter: false})
}
private onSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.keyCode === 27) { // ESC: Clear search
2020-11-17 19:53:46 +01:00
if (this.searchFieldRef.current) {
this.searchFieldRef.current.text = ''
}
2020-10-26 20:48:15 +01:00
this.setState({isSearching: false})
this.props.setSearchText(undefined)
e.preventDefault()
}
}
private searchChanged(text?: string) {
this.props.setSearchText(text)
}
private async testAddCards(count: number) {
const {boardTree} = this.props
const {board, activeView} = boardTree
const startCount = boardTree.cards.length
let optionIndex = 0
2020-11-12 23:55:55 +01:00
mutator.performAsUndoGroup(async () => {
for (let i = 0; i < count; i++) {
const card = new MutableCard()
card.parentId = boardTree.board.id
card.rootId = boardTree.board.rootId
card.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
card.title = `Test Card ${startCount + i + 1}`
card.icon = BlockIcons.shared.randomIcon()
if (boardTree.groupByProperty && boardTree.groupByProperty.options.length > 0) {
// Cycle through options
const option = boardTree.groupByProperty.options[optionIndex]
optionIndex = (optionIndex + 1) % boardTree.groupByProperty.options.length
card.properties[boardTree.groupByProperty.id] = option.id
}
2020-11-12 23:55:55 +01:00
mutator.insertBlock(card, 'test add card')
}
})
}
2020-11-09 19:25:00 +01:00
private async testDistributeCards() {
const {boardTree} = this.props
2020-11-12 23:55:55 +01:00
mutator.performAsUndoGroup(async () => {
2020-11-09 19:25:00 +01:00
let optionIndex = 0
for (const card of boardTree.cards) {
if (boardTree.groupByProperty && boardTree.groupByProperty.options.length > 0) {
// Cycle through options
const option = boardTree.groupByProperty.options[optionIndex]
optionIndex = (optionIndex + 1) % boardTree.groupByProperty.options.length
const newCard = new MutableCard(card)
if (newCard.properties[boardTree.groupByProperty.id] !== option.id) {
newCard.properties[boardTree.groupByProperty.id] = option.id
2020-11-12 23:55:55 +01:00
mutator.updateBlock(newCard, card, 'test distribute cards')
2020-11-09 19:25:00 +01:00
}
}
}
})
}
private async testRandomizeIcons() {
const {boardTree} = this.props
2020-11-12 23:55:55 +01:00
mutator.performAsUndoGroup(async () => {
for (const card of boardTree.cards) {
2020-11-12 23:55:55 +01:00
mutator.changeIcon(card, BlockIcons.shared.randomIcon(), 'randomize icon')
}
})
}
render(): JSX.Element {
const {boardTree, showView, withGroupBy, intl} = this.props
const {board, activeView} = boardTree
const hasFilter = activeView.filter && activeView.filter.filters?.length > 0
const hasSort = activeView.sortOptions.length > 0
return (
2020-10-25 22:29:20 +01:00
<div className='ViewHeader'>
<Editable
2020-10-27 11:40:32 +01:00
style={{color: 'rgb(var(--main-fg))', fontWeight: 600}}
text={activeView.title}
placeholderText='Untitled View'
onChanged={(text) => {
mutator.changeTitle(activeView, text)
}}
/>
<MenuWrapper>
<IconButton icon={<DropdownIcon/>}/>
<ViewMenu
board={board}
boardTree={boardTree}
showView={showView}
/>
</MenuWrapper>
<div className='octo-spacer'/>
<MenuWrapper>
<Button>
<FormattedMessage
id='ViewHeader.properties'
defaultMessage='Properties'
/>
</Button>
<Menu>
{boardTree.board.cardProperties.map((option: IPropertyTemplate) => (
<Menu.Switch
key={option.id}
id={option.id}
name={option.name}
isOn={activeView.visiblePropertyIds.includes(option.id)}
onClick={(propertyId: string) => {
let newVisiblePropertyIds = []
if (activeView.visiblePropertyIds.includes(propertyId)) {
newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o: string) => o !== propertyId)
} else {
newVisiblePropertyIds = [...activeView.visiblePropertyIds, propertyId]
}
mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
}}
/>
))}
</Menu>
</MenuWrapper>
{withGroupBy &&
<MenuWrapper>
<Button>
<FormattedMessage
id='ViewHeader.group-by'
defaultMessage='Group by {property}'
values={{
property: (
<span
2020-10-27 11:40:32 +01:00
style={{color: 'rgb(var(--main-fg))'}}
id='groupByLabel'
>
{boardTree.groupByProperty?.name}
</span>
),
}}
/>
</Button>
<Menu>
{boardTree.board.cardProperties.filter((o: IPropertyTemplate) => o.type === 'select').map((option: IPropertyTemplate) => (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={boardTree.activeView.groupById === option.id ? <CheckIcon/> : undefined}
onClick={(id) => {
if (boardTree.activeView.groupById === id) {
return
}
mutator.changeViewGroupById(boardTree.activeView, id)
}}
/>
))}
</Menu>
</MenuWrapper>}
2020-11-01 17:25:39 +01:00
<div className='filter-container'>
<Button
active={hasFilter}
2020-12-02 18:28:52 +01:00
onClick={this.showFilterDialog}
2020-11-01 17:25:39 +01:00
>
<FormattedMessage
id='ViewHeader.filter'
defaultMessage='Filter'
/>
</Button>
2020-12-02 18:28:52 +01:00
{this.state.showFilter &&
<FilterComponent
boardTree={boardTree}
onClose={this.hideFilterDialog}
/>}
</div>
<MenuWrapper>
2020-11-01 17:25:39 +01:00
<Button active={hasSort}>
<FormattedMessage
id='ViewHeader.sort'
defaultMessage='Sort'
/>
2020-11-01 17:25:39 +01:00
</Button>
<Menu>
2020-10-28 18:46:36 +01:00
{(activeView.sortOptions.length > 0) &&
<>
<Menu.Text
id='manual'
name='Manual'
onClick={() => {
// This sets the manual card order to the currently displayed order
// Note: Perform this as a single update to change both properties correctly
const newView = new MutableBoardView(activeView)
2020-10-28 21:26:55 +01:00
newView.cardOrder = boardTree.orderedCards().map((o) => o.id)
2020-10-28 18:46:36 +01:00
newView.sortOptions = []
mutator.updateBlock(newView, activeView, 'reorder')
}}
/>
<Menu.Text
id='revert'
name='Revert'
onClick={() => {
mutator.changeViewSortOptions(activeView, [])
}}
/>
2020-10-26 17:56:12 +01:00
2020-10-28 18:46:36 +01:00
<Menu.Separator/>
</>
}
2020-10-26 17:56:12 +01:00
2020-11-12 23:55:55 +01:00
{this.sortDisplayOptions().map((option) => {
let rightIcon: JSX.Element | undefined
if (activeView.sortOptions.length > 0) {
const sortOption = activeView.sortOptions[0]
if (sortOption.propertyId === option.id) {
rightIcon = sortOption.reversed ? <SortUpIcon/> : <SortDownIcon/>
}
}
return (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={rightIcon}
onClick={(propertyId: string) => {
let newSortOptions: ISortOption[] = []
if (activeView.sortOptions[0] && activeView.sortOptions[0].propertyId === propertyId) {
// Already sorting by name, so reverse it
2020-11-12 23:55:55 +01:00
newSortOptions = [
{propertyId, reversed: !activeView.sortOptions[0].reversed},
]
} else {
newSortOptions = [
{propertyId, reversed: false},
]
}
mutator.changeViewSortOptions(activeView, newSortOptions)
}}
/>
)
})}
</Menu>
</MenuWrapper>
{this.state.isSearching &&
<Editable
ref={this.searchFieldRef}
text={boardTree.getSearchText()}
placeholderText={intl.formatMessage({id: 'ViewHeader.search-text', defaultMessage: 'Search text'})}
2020-10-27 11:40:32 +01:00
style={{color: 'rgb(var(--main-fg))'}}
onChanged={(text) => {
this.searchChanged(text)
}}
onKeyDown={(e) => {
this.onSearchKeyDown(e)
}}
/>}
{!this.state.isSearching &&
<Button onClick={() => this.setState({isSearching: true})}>
<FormattedMessage
id='ViewHeader.search'
defaultMessage='Search'
/>
</Button>}
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='exportCsv'
name={intl.formatMessage({id: 'ViewHeader.export-csv', defaultMessage: 'Export to CSV'})}
onClick={() => CsvExporter.exportTableCsv(boardTree)}
/>
<Menu.Text
id='exportBoardArchive'
2020-12-11 20:10:25 +01:00
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export board archive'})}
onClick={() => Archiver.exportBoardTree(boardTree)}
/>
2020-11-09 19:25:00 +01:00
2020-11-10 19:09:08 +01:00
<Menu.Separator/>
2020-11-09 19:25:00 +01:00
<Menu.Text
id='testAdd100Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-100-cards', defaultMessage: 'TEST: Add 100 cards'})}
onClick={() => this.testAddCards(100)}
/>
<Menu.Text
id='testAdd1000Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-1000-cards', defaultMessage: 'TEST: Add 1,000 cards'})}
onClick={() => this.testAddCards(1000)}
/>
2020-11-09 19:25:00 +01:00
<Menu.Text
id='testDistributeCards'
name={intl.formatMessage({id: 'ViewHeader.test-distribute-cards', defaultMessage: 'TEST: Distribute cards'})}
onClick={() => this.testDistributeCards()}
/>
<Menu.Text
id='testRandomizeIcons'
name={intl.formatMessage({id: 'ViewHeader.test-randomize-icons', defaultMessage: 'TEST: Randomize icons'})}
onClick={() => this.testRandomizeIcons()}
/>
</Menu>
</MenuWrapper>
2020-11-10 20:23:08 +01:00
<ButtonWithMenu
onClick={() => {
2020-11-10 20:23:08 +01:00
this.props.addCard()
}}
text={(
<FormattedMessage
id='ViewHeader.new'
defaultMessage='New'
/>
)}
>
<Menu position='left'>
{boardTree.cardTemplates.length > 0 && <>
<Menu.Label>
<b>
<FormattedMessage
id='ViewHeader.select-a-template'
defaultMessage='Select a template'
/>
</b>
</Menu.Label>
2020-11-10 20:23:08 +01:00
<Menu.Separator/>
</>}
2020-11-10 20:23:08 +01:00
{boardTree.cardTemplates.map((cardTemplate) => {
2020-12-14 22:06:41 +01:00
const displayName = cardTemplate.title || intl.formatMessage({id: 'ViewHeader.untitled', defaultMessage: 'Untitled'})
2020-11-10 20:23:08 +01:00
return (
<Menu.Text
key={cardTemplate.id}
id={cardTemplate.id}
2020-11-17 20:17:44 +01:00
name={displayName}
2020-12-14 22:06:41 +01:00
icon={<div className='Icon'>{cardTemplate.icon}</div>}
2020-11-10 20:23:08 +01:00
onClick={() => {
2020-11-11 18:21:16 +01:00
this.props.addCardFromTemplate(cardTemplate.id)
2020-11-10 20:23:08 +01:00
}}
rightIcon={
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
id='edit'
name={intl.formatMessage({id: 'ViewHeader.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
2020-11-11 18:21:16 +01:00
this.props.editCardTemplate(cardTemplate.id)
2020-11-10 20:23:08 +01:00
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'ViewHeader.delete-template', defaultMessage: 'Delete'})}
2020-11-11 18:21:16 +01:00
onClick={async () => {
await mutator.deleteBlock(cardTemplate, 'delete card template')
2020-11-10 20:23:08 +01:00
}}
/>
</Menu>
</MenuWrapper>
}
/>
)
})}
<Menu.Text
id='empty-template'
name={intl.formatMessage({id: 'ViewHeader.empty-card', defaultMessage: 'Empty card'})}
2020-12-14 22:06:41 +01:00
icon={<CardIcon/>}
2020-11-10 20:23:08 +01:00
onClick={() => {
this.props.addCard()
}}
/>
<Menu.Text
2020-11-10 20:23:08 +01:00
id='add-template'
name={intl.formatMessage({id: 'ViewHeader.add-template', defaultMessage: '+ New template'})}
onClick={() => this.props.addCardTemplate()}
/>
</Menu>
</ButtonWithMenu>
</div>
)
}
2020-11-04 20:21:09 +01:00
private sortDisplayOptions() {
const {boardTree} = this.props
const options = boardTree.board.cardProperties.map((o) => ({id: o.id, name: o.name}))
options.unshift({id: Constants.titleColumnId, name: 'Name'})
return options
}
}
export default injectIntl(ViewHeader)