focalboard/webapp/src/components/cardDetail.tsx

507 lines
22 KiB
TypeScript
Raw Normal View History

2020-10-20 21:50:53 +02:00
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
2020-10-20 21:52:56 +02:00
import React from 'react'
2020-10-21 03:28:55 +02:00
import {BlockIcons} from '../blockIcons'
import {MutableCommentBlock} from '../blocks/commentBlock'
import {IOrderedBlock} from '../blocks/orderedBlock'
import {MutableTextBlock} from '../blocks/textBlock'
import {BoardTree} from '../viewModel/boardTree'
import {CardTree, MutableCardTree} from '../viewModel/cardTree'
import {Menu as OldMenu, MenuOption} from '../menu'
import mutator from '../mutator'
import {OctoListener} from '../octoListener'
import {IBlock} from '../blocks/block'
import {OctoUtils} from '../octoUtils'
import {PropertyMenu} from '../propertyMenu'
import {Utils} from '../utils'
2020-10-21 03:28:55 +02:00
import Button from './button'
import {Editable} from './editable'
import {MarkdownEditor} from './markdownEditor'
type Props = {
2020-10-20 21:50:53 +02:00
boardTree: BoardTree
2020-10-21 03:28:55 +02:00
cardId: string
}
type State = {
2020-10-20 21:50:53 +02:00
isHoverOnCover: boolean
cardTree?: CardTree
}
export default class CardDetail extends React.Component<Props, State> {
2020-10-20 21:50:53 +02:00
private titleRef = React.createRef<Editable>()
2020-10-21 18:32:36 +02:00
private cardListener?: OctoListener
2020-10-20 21:50:53 +02:00
constructor(props: Props) {
super(props)
2020-10-20 21:50:53 +02:00
this.state = {isHoverOnCover: false}
}
componentDidMount() {
this.cardListener = new OctoListener()
this.cardListener.open([this.props.cardId], async (blockId) => {
2020-10-21 03:28:55 +02:00
Utils.log(`cardListener.onChanged: ${blockId}`)
2020-10-20 21:50:53 +02:00
await cardTree.sync()
this.setState({...this.state, cardTree})
2020-10-20 21:52:56 +02:00
})
const cardTree = new MutableCardTree(this.props.cardId)
2020-10-20 21:50:53 +02:00
cardTree.sync().then(() => {
this.setState({...this.state, cardTree})
setTimeout(() => {
2020-10-20 21:50:53 +02:00
if (this.titleRef.current) {
this.titleRef.current.focus()
}
}, 0)
2020-10-20 21:52:56 +02:00
})
2020-10-20 21:50:53 +02:00
}
2020-10-21 18:32:36 +02:00
componentWillUnmount() {
this.cardListener?.close()
this.cardListener = undefined
}
2020-10-20 21:50:53 +02:00
render() {
const {boardTree} = this.props
2020-10-21 03:28:55 +02:00
const {cardTree} = this.state
2020-10-20 21:50:53 +02:00
const {board} = boardTree
if (!cardTree) {
return null
2020-10-20 21:50:53 +02:00
}
2020-10-21 03:28:55 +02:00
const {card, comments} = cardTree
2020-10-20 21:50:53 +02:00
2020-10-20 21:52:56 +02:00
const newCommentPlaceholderText = 'Add a comment...'
2020-10-20 21:50:53 +02:00
const backgroundRef = React.createRef<HTMLDivElement>()
2020-10-20 21:50:53 +02:00
const newCommentRef = React.createRef<Editable>()
const sendCommentButtonRef = React.createRef<HTMLDivElement>()
let contentElements
if (cardTree.contents.length > 0) {
contentElements =
(<div className='octo-content'>
{cardTree.contents.map((block) => {
if (block.type === 'text') {
const cardText = block.title
return (<div
2020-10-20 21:50:53 +02:00
key={block.id}
className='octo-block octo-hover-container'
2020-10-22 18:46:06 +02:00
>
2020-10-20 21:50:53 +02:00
<div className='octo-block-margin'>
<div
2020-10-20 21:50:53 +02:00
className='octo-button octo-hovercontrol square octo-hover-item'
onClick={(e) => {
this.showContentBlockMenu(e, block)
}}
>
<div className='imageOptions'/>
</div>
</div>
2020-10-20 21:50:53 +02:00
<MarkdownEditor
text={cardText}
placeholderText='Edit text...'
onChanged={(text) => {
2020-10-20 21:50:53 +02:00
Utils.log(`change text ${block.id}, ${text}`)
mutator.changeTitle(block, text, 'edit card text')
}}
/>
2020-10-20 21:50:53 +02:00
</div>)
} else if (block.type === 'image') {
const url = block.fields.url
return (<div
2020-10-20 21:50:53 +02:00
key={block.id}
className='octo-block octo-hover-container'
2020-10-22 18:46:06 +02:00
>
2020-10-20 21:50:53 +02:00
<div className='octo-block-margin'>
<div
2020-10-20 21:50:53 +02:00
className='octo-button octo-hovercontrol square octo-hover-item'
onClick={(e) => {
this.showContentBlockMenu(e, block)
}}
>
<div className='imageOptions'/>
</div>
</div>
<img
2020-10-20 21:50:53 +02:00
src={url}
alt={block.title}
/>
2020-10-20 21:50:53 +02:00
</div>)
}
2020-10-20 21:50:53 +02:00
return <div/>
})}
2020-10-20 21:50:53 +02:00
</div>)
} else {
2020-10-20 21:50:53 +02:00
contentElements = (<div className='octo-content'>
<div className='octo-block octo-hover-container'>
<div className='octo-block-margin'/>
<MarkdownEditor
text=''
placeholderText='Add a description...'
onChanged={(text) => {
2020-10-21 03:28:55 +02:00
const block = new MutableTextBlock()
block.parentId = card.id
block.title = text
block.order = cardTree.contents.length * 1000
2020-10-20 21:50:53 +02:00
mutator.insertBlock(block, 'add card text')
}}
/>
</div>
</div>)
}
2020-10-20 21:50:53 +02:00
const icon = card.icon
// TODO: Replace this placeholder
2020-10-20 21:52:56 +02:00
const username = 'John Smith'
const userImageUrl = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" style="fill: rgb(192, 192, 192);"><rect width="100" height="100" /></svg>'
2020-10-20 21:50:53 +02:00
return (
2020-10-20 21:50:53 +02:00
<>
<div className='content'>
2020-10-20 21:50:53 +02:00
{icon ?
<div
2020-10-20 21:50:53 +02:00
className='octo-button octo-icon octo-card-icon'
onClick={(e) => {
this.iconClicked(e)
}}
>{icon}</div> :
undefined
}
2020-10-20 21:50:53 +02:00
<div
className='octo-hovercontrols'
onMouseOver={() => {
2020-10-20 21:50:53 +02:00
this.setState({...this.state, isHoverOnCover: true})
}}
onMouseLeave={() => {
2020-10-20 21:50:53 +02:00
this.setState({...this.state, isHoverOnCover: false})
}}
>
<Button
2020-10-20 21:50:53 +02:00
style={{display: (!icon && this.state.isHoverOnCover) ? null : 'none'}}
onClick={() => {
const newIcon = BlockIcons.shared.randomIcon()
2020-10-20 21:50:53 +02:00
mutator.changeIcon(card, newIcon)
}}
>Add Icon</Button>
</div>
2020-10-20 21:50:53 +02:00
<Editable
ref={this.titleRef}
className='title'
text={card.title}
placeholderText='Untitled'
onChanged={(text) => {
2020-10-20 21:50:53 +02:00
mutator.changeTitle(card, text)
}}
/>
2020-10-20 21:50:53 +02:00
{/* Property list */}
<div className='octo-propertylist'>
{board.cardProperties.map((propertyTemplate) => {
2020-10-20 21:50:53 +02:00
return (
<div
key={propertyTemplate.id}
className='octo-propertyrow'
>
<div
className='octo-button octo-propertyname'
onClick={(e) => {
const menu = PropertyMenu.shared
menu.property = propertyTemplate
2020-10-20 21:50:53 +02:00
menu.onNameChanged = (propertyName) => {
Utils.log('menu.onNameChanged')
mutator.renameProperty(board, propertyTemplate.id, propertyName)
2020-10-20 21:52:56 +02:00
}
2020-10-20 21:50:53 +02:00
menu.onMenuClicked = async (command) => {
switch (command) {
case 'type-text':
await mutator.changePropertyType(board, propertyTemplate, 'text')
break
case 'type-number':
2020-10-20 21:50:53 +02:00
await mutator.changePropertyType(board, propertyTemplate, 'number')
break
2020-10-20 21:50:53 +02:00
case 'type-createdTime':
await mutator.changePropertyType(board, propertyTemplate, 'createdTime')
2020-10-20 21:52:56 +02:00
break
case 'type-updatedTime':
2020-10-20 21:50:53 +02:00
await mutator.changePropertyType(board, propertyTemplate, 'updatedTime')
2020-10-20 21:52:56 +02:00
break
case 'type-select':
2020-10-20 21:50:53 +02:00
await mutator.changePropertyType(board, propertyTemplate, 'select')
2020-10-20 21:52:56 +02:00
break
2020-10-20 21:50:53 +02:00
case 'delete':
await mutator.deleteProperty(boardTree, propertyTemplate.id)
2020-10-20 21:52:56 +02:00
break
default:
2020-10-20 21:50:53 +02:00
Utils.assertFailure(`Unhandled menu id: ${command}`)
}
}
2020-10-20 21:50:53 +02:00
menu.showAtElement(e.target as HTMLElement)
}}
2020-10-20 21:50:53 +02:00
>{propertyTemplate.name}</div>
{OctoUtils.propertyValueEditableElement(card, propertyTemplate)}
</div>
)
2020-10-20 21:50:53 +02:00
})}
<div
2020-10-20 21:50:53 +02:00
className='octo-button octo-propertyname'
style={{textAlign: 'left', width: '150px', color: 'rgba(55, 53, 37, 0.4)'}}
onClick={async () => {
// TODO: Show UI
await mutator.insertPropertyTemplate(boardTree)
}}
2020-10-20 21:50:53 +02:00
>+ Add a property</div>
</div>
2020-10-20 21:50:53 +02:00
{/* Comments */}
<hr/>
<div className='commentlist'>
{comments.map((comment) => {
2020-10-20 21:50:53 +02:00
const optionsButtonRef = React.createRef<HTMLDivElement>()
const showCommentMenu = (e: React.MouseEvent, activeComment: IBlock) => {
OldMenu.shared.options = [
{id: 'delete', name: 'Delete'},
2020-10-20 21:50:53 +02:00
]
OldMenu.shared.onMenuClicked = (id) => {
2020-10-20 21:50:53 +02:00
switch (id) {
case 'delete': {
mutator.deleteBlock(activeComment)
2020-10-20 21:52:56 +02:00
break
2020-10-20 21:50:53 +02:00
}
}
}
2020-10-20 21:50:53 +02:00
OldMenu.shared.showAtElement(e.target as HTMLElement)
2020-10-20 21:52:56 +02:00
}
2020-10-20 21:50:53 +02:00
return (<div
key={comment.id}
className='comment'
onMouseOver={() => {
optionsButtonRef.current.style.display = null
}}
onMouseLeave={() => {
2020-10-20 21:52:56 +02:00
optionsButtonRef.current.style.display = 'none'
2020-10-20 21:50:53 +02:00
}}
2020-10-22 18:46:06 +02:00
>
2020-10-20 21:50:53 +02:00
<div className='comment-header'>
<img
className='comment-avatar'
src={userImageUrl}
/>
<div className='comment-username'>{username}</div>
<div className='comment-date'>{(new Date(comment.createAt)).toLocaleTimeString()}</div>
<div
ref={optionsButtonRef}
className='octo-hoverbutton square'
style={{display: 'none'}}
onClick={(e) => {
showCommentMenu(e, comment)
}}
>...</div>
</div>
<div className='comment-text'>{comment.title}</div>
</div>)
})}
2020-10-20 21:50:53 +02:00
{/* New comment */}
2020-10-20 21:50:53 +02:00
<div className='commentrow'>
2020-10-20 21:50:53 +02:00
<img
className='comment-avatar'
src={userImageUrl}
/>
2020-10-20 21:50:53 +02:00
<Editable
ref={newCommentRef}
className='newcomment'
placeholderText={newCommentPlaceholderText}
onChanged={(text) => { }}
onFocus={() => {
2020-10-20 21:50:53 +02:00
sendCommentButtonRef.current.style.display = null
}}
onBlur={() => {
if (!newCommentRef.current.text) {
2020-10-20 21:52:56 +02:00
sendCommentButtonRef.current.style.display = 'none'
}
}}
onKeyDown={(e) => {
2020-10-20 21:50:53 +02:00
if (e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
sendCommentButtonRef.current.click()
2020-10-20 21:50:53 +02:00
}
}}
/>
2020-10-20 21:50:53 +02:00
<div
ref={sendCommentButtonRef}
className='octo-button filled'
style={{display: 'none'}}
onClick={(e) => {
const text = newCommentRef.current.text
2020-10-22 18:46:06 +02:00
Utils.log(`Send comment: ${newCommentRef.current.text}`)
this.sendComment(text)
newCommentRef.current.text = undefined
2020-10-20 21:50:53 +02:00
newCommentRef.current.blur()
}}
>Send</div>
2020-10-20 21:50:53 +02:00
</div>
</div>
2020-10-20 21:50:53 +02:00
<hr/>
</div>
{/* Content blocks */}
2020-10-20 21:50:53 +02:00
<div className='content fullwidth'>
2020-10-20 21:50:53 +02:00
{contentElements}
</div>
<div className='content'>
2020-10-20 21:50:53 +02:00
<div className='octo-hoverpanel octo-hover-container'>
<div
2020-10-20 21:50:53 +02:00
className='octo-button octo-hovercontrol octo-hover-item'
onClick={(e) => {
OldMenu.shared.options = [
{id: 'text', name: 'Text'},
2020-10-20 21:50:53 +02:00
{id: 'image', name: 'Image'},
]
2020-10-20 21:50:53 +02:00
OldMenu.shared.onMenuClicked = async (optionId: string, type?: string) => {
switch (optionId) {
2020-10-20 21:50:53 +02:00
case 'text':
2020-10-21 03:28:55 +02:00
const block = new MutableTextBlock()
block.parentId = card.id
block.order = cardTree.contents.length * 1000
2020-10-20 21:50:53 +02:00
await mutator.insertBlock(block, 'add text')
break
case 'image':
2020-10-20 21:50:53 +02:00
Utils.selectLocalFile(
(file) => {
mutator.createImageBlock(card.id, file, cardTree.contents.length * 1000)
2020-10-20 21:50:53 +02:00
},
'.jpg,.jpeg,.png')
break
2020-10-20 21:50:53 +02:00
}
}
OldMenu.shared.showAtElement(e.target as HTMLElement)
2020-10-20 21:50:53 +02:00
}}
>Add content</div>
</div>
2020-10-20 21:50:53 +02:00
</div>
</>
2020-10-20 21:50:53 +02:00
)
}
async sendComment(text: string) {
2020-10-21 03:28:55 +02:00
const {cardId} = this.props
2020-10-20 21:50:53 +02:00
2020-10-21 03:28:55 +02:00
Utils.assertValue(cardId)
2020-10-20 21:50:53 +02:00
const block = new MutableCommentBlock({parentId: cardId, title: text})
2020-10-20 21:50:53 +02:00
await mutator.insertBlock(block, 'add comment')
}
private showContentBlockMenu(e: React.MouseEvent, block: IOrderedBlock) {
const {cardTree} = this.state
const {cardId} = this.props
const index = cardTree.contents.indexOf(block)
2020-10-20 21:50:53 +02:00
const options: MenuOption[] = []
if (index > 0) {
2020-10-20 21:50:53 +02:00
options.push({id: 'moveUp', name: 'Move up'})
}
if (index < cardTree.contents.length - 1) {
2020-10-20 21:50:53 +02:00
options.push({id: 'moveDown', name: 'Move down'})
}
options.push(
2020-10-20 21:50:53 +02:00
{id: 'insertAbove', name: 'Insert above', type: 'submenu'},
{id: 'delete', name: 'Delete'},
2020-10-20 21:50:53 +02:00
)
OldMenu.shared.options = options
OldMenu.shared.subMenuOptions.set('insertAbove', [
2020-10-20 21:50:53 +02:00
{id: 'text', name: 'Text'},
{id: 'image', name: 'Image'},
2020-10-20 21:50:53 +02:00
])
OldMenu.shared.onMenuClicked = (optionId: string, type?: string) => {
switch (optionId) {
2020-10-20 21:50:53 +02:00
case 'moveUp': {
if (index < 1) {
Utils.logError(`Unexpected index ${index}`); return
}
const previousBlock = cardTree.contents[index - 1]
const newOrder = OctoUtils.getOrderBefore(previousBlock, cardTree.contents)
2020-10-20 21:50:53 +02:00
Utils.log(`moveUp ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move up')
2020-10-20 21:52:56 +02:00
break
2020-10-20 21:50:53 +02:00
}
case 'moveDown': {
2020-10-20 21:50:53 +02:00
if (index >= cardTree.contents.length - 1) {
Utils.logError(`Unexpected index ${index}`); return
}
const nextBlock = cardTree.contents[index + 1]
const newOrder = OctoUtils.getOrderAfter(nextBlock, cardTree.contents)
2020-10-20 21:50:53 +02:00
Utils.log(`moveDown ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move down')
2020-10-20 21:52:56 +02:00
break
2020-10-20 21:50:53 +02:00
}
case 'insertAbove-text': {
2020-10-21 03:28:55 +02:00
const newBlock = new MutableTextBlock()
newBlock.parentId = cardId
2020-10-20 21:50:53 +02:00
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, cardTree.contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text')
break
2020-10-20 21:50:53 +02:00
}
case 'insertAbove-image': {
Utils.selectLocalFile(
(file) => {
mutator.createImageBlock(cardId, file, OctoUtils.getOrderBefore(block, cardTree.contents))
2020-10-20 21:50:53 +02:00
},
'.jpg,.jpeg,.png')
break
}
2020-10-20 21:50:53 +02:00
case 'delete': {
mutator.deleteBlock(block)
2020-10-20 21:52:56 +02:00
break
2020-10-20 21:50:53 +02:00
}
}
}
OldMenu.shared.showAtElement(e.target as HTMLElement)
2020-10-20 21:50:53 +02:00
}
private iconClicked(e: React.MouseEvent) {
2020-10-21 03:28:55 +02:00
const {cardTree} = this.state
const {card} = cardTree
2020-10-20 21:50:53 +02:00
OldMenu.shared.options = [
{id: 'random', name: 'Random'},
{id: 'remove', name: 'Remove Icon'},
2020-10-20 21:50:53 +02:00
]
OldMenu.shared.onMenuClicked = (optionId: string, type?: string) => {
switch (optionId) {
2020-10-20 21:50:53 +02:00
case 'remove':
mutator.changeIcon(card, undefined, 'remove icon')
break
case 'random':
2020-10-20 21:50:53 +02:00
const newIcon = BlockIcons.shared.randomIcon()
mutator.changeIcon(card, newIcon)
break
}
2020-10-20 21:50:53 +02:00
}
OldMenu.shared.showAtElement(e.target as HTMLElement)
}
close() {
OldMenu.shared.hide()
PropertyMenu.shared.hide()
}
}