Merge pull request #68 from mattermost/content-blocks

Refactor content blocks
This commit is contained in:
Chen-I Lim 2021-03-15 10:20:44 -07:00 committed by GitHub
commit 4ca343d84c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 353 additions and 147 deletions

View file

@ -15,22 +15,14 @@
"CardDetail.add-icon": "Add icon",
"CardDetail.add-property": "+ Add a property",
"CardDetail.addCardText": "add card text",
"CardDetail.image": "Image",
"CardDetail.new-comment-placeholder": "Add a comment...",
"CardDetail.text": "Text",
"CardDialog.editing-template": "You're editing a template",
"CardDialog.nocard": "This card doesn't exist or is inaccessible",
"Comment.delete": "Delete",
"CommentsList.send": "Send",
"ContentBlock.Delete": "Delete",
"ContentBlock.DeleteAction": "delete",
"ContentBlock.Text": "Text",
"ContentBlock.addDivider": "add divider",
"ContentBlock.addImage": "add image",
"ContentBlock.addText": "add text",
"ContentBlock.divider": "Divider",
"ContentBlock.editCardText": "edit card text",
"ContentBlock.editText": "Edit text...",
"ContentBlock.addElement": "add {type}",
"ContentBlock.insertAbove": "Insert above",
"ContentBlock.moveDown": "Move down",
"ContentBlock.moveUp": "Move up",
@ -98,7 +90,6 @@
"Sidebar.settings": "Settings",
"Sidebar.spanish": "Spanish",
"Sidebar.template-from-board": "New template from board",
"Sidebar.title": "Boards",
"Sidebar.untitled": "Untitled",
"Sidebar.untitled-board": "(Untitled Board)",
"Sidebar.untitled-view": "(Untitled View)",

View file

@ -2,7 +2,10 @@
// See LICENSE.txt for license information.
import {Utils} from '../utils'
type BlockTypes = 'board' | 'view' | 'card' | 'text' | 'image' | 'divider' | 'comment'
const contentBlockTypes = ['text', 'image', 'divider'] as const
const blockTypes = [...contentBlockTypes, 'board', 'view', 'card', 'comment'] as const
type ContentBlockTypes = typeof contentBlockTypes[number]
type BlockTypes = typeof blockTypes[number]
interface IBlock {
readonly id: string
@ -69,4 +72,5 @@ class MutableBlock implements IMutableBlock {
}
}
export {IBlock, IMutableBlock, MutableBlock}
export type {ContentBlockTypes, BlockTypes}
export {blockTypes, contentBlockTypes, IBlock, IMutableBlock, MutableBlock}

View file

@ -4,6 +4,7 @@ import React from 'react'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {BlockIcons} from '../blockIcons'
import {BlockTypes} from '../blocks/block'
import {PropertyType} from '../blocks/board'
import {MutableTextBlock} from '../blocks/textBlock'
import mutator from '../mutator'
@ -20,6 +21,7 @@ import PropertyMenu from '../widgets/propertyMenu'
import BlockIconSelector from './blockIconSelector'
import './cardDetail.scss'
import CommentsList from './commentsList'
import {ContentHandler, contentRegistry} from './content/contentRegistry'
import ContentBlock from './contentBlock'
import {MarkdownEditor} from './markdownEditor'
import PropertyValueElement from './propertyValueElement'
@ -56,7 +58,7 @@ class CardDetail extends React.Component<Props, State> {
}
render() {
const {boardTree, cardTree, intl} = this.props
const {boardTree, cardTree} = this.props
const {board} = boardTree
if (!cardTree) {
return null
@ -221,31 +223,7 @@ class CardDetail extends React.Component<Props, State> {
/>
</Button>
<Menu position='top'>
<Menu.Text
id='text'
name={intl.formatMessage({id: 'CardDetail.text', defaultMessage: 'Text'})}
onClick={() => {
this.addTextBlock('')
}}
/>
<Menu.Text
id='image'
name={intl.formatMessage({id: 'CardDetail.image', defaultMessage: 'Image'})}
onClick={() => Utils.selectLocalFile((file) => {
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'ContentBlock.addImage', defaultMessage: 'add image'})
const newBlock = await mutator.createImageBlock(card, file, description)
if (newBlock) {
const contentOrder = card.contentOrder.slice()
contentOrder.push(newBlock.id)
await mutator.changeCardContentOrder(card, contentOrder, description)
}
})
},
'.jpg,.jpeg,.png',
)}
/>
{contentRegistry.contentTypes.map((type) => this.addContentMenu(type))}
</Menu>
</MenuWrapper>
</div>
@ -254,6 +232,46 @@ class CardDetail extends React.Component<Props, State> {
)
}
private addContentMenu(type: BlockTypes): JSX.Element {
const {intl} = this.props
const handler = contentRegistry.getHandler(type)
if (!handler) {
Utils.logError(`addContentMenu, unknown content type: ${type}`)
return <></>
}
return (
<Menu.Text
key={type}
id={type}
name={handler.getDisplayText(intl)}
icon={handler.getIcon()}
onClick={() => {
this.addBlock(handler)
}}
/>
)
}
private async addBlock(handler: ContentHandler) {
const {intl, cardTree} = this.props
const {card} = cardTree
const newBlock = await handler.createBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
const contentOrder = card.contentOrder.slice()
contentOrder.push(newBlock.id)
const typeName = handler.getDisplayText(intl)
const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type}'}, {type: typeName})
mutator.performAsUndoGroup(async () => {
await mutator.insertBlock(newBlock, description)
await mutator.changeCardContentOrder(card, contentOrder, description)
})
}
private addTextBlock(text: string): void {
const {intl, cardTree} = this.props
const {card} = cardTree

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {IContentBlock} from '../../blocks/contentBlock'
import {Utils} from '../../utils'
import {contentRegistry} from './contentRegistry'
// Need to require here to prevent webpack from tree-shaking these away
// TODO: Update webpack to avoid this
import './textElement'
import './imageElement'
import './dividerElement'
type Props = {
block: IContentBlock
readonly: boolean
intl: IntlShape
}
class ContentElement extends React.PureComponent<Props> {
public render(): JSX.Element | null {
const {block, intl, readonly} = this.props
const handler = contentRegistry.getHandler(block.type)
if (!handler) {
Utils.logError(`ContentElement, unknown content type: ${block.type}`)
return null
}
return handler.createComponent(block, intl, readonly)
}
}
export default injectIntl(ContentElement)

View file

@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable react/require-optimization */
import {IntlShape} from 'react-intl'
import {BlockTypes} from '../../blocks/block'
import {IContentBlock, MutableContentBlock} from '../../blocks/contentBlock'
import {Utils} from '../../utils'
type ContentHandler = {
type: BlockTypes,
getDisplayText: (intl: IntlShape) => string,
getIcon: () => JSX.Element,
createBlock: () => Promise<MutableContentBlock>,
createComponent: (block: IContentBlock, intl: IntlShape, readonly: boolean) => JSX.Element,
}
class ContentRegistry {
private registry: Map<BlockTypes, ContentHandler> = new Map()
get contentTypes(): BlockTypes[] {
return [...this.registry.keys()]
}
registerContentType(entry: ContentHandler) {
if (this.isContentType(entry.type)) {
Utils.logError(`registerContentType, already registered type: ${entry.type}`)
return
}
this.registry.set(entry.type, entry)
}
isContentType(type: BlockTypes): boolean {
return this.registry.has(type)
}
getHandler(type: BlockTypes): ContentHandler | undefined {
return this.registry.get(type)
}
}
const contentRegistry = new ContentRegistry()
export type {ContentHandler}
export {contentRegistry}

View file

@ -0,0 +1,6 @@
.DividerElement {
padding-top: 16px;
border-bottom: 1px solid rgba(var(--body-color), 0.09);
margin-bottom: 17px;
flex-grow: 1;
}

View file

@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {MutableDividerBlock} from '../../blocks/dividerBlock'
import DividerIcon from '../../widgets/icons/divider'
import {contentRegistry} from './contentRegistry'
import './dividerElement.scss'
class DividerElement extends React.PureComponent {
render(): JSX.Element {
return <div className='DividerElement'/>
}
}
contentRegistry.registerContentType({
type: 'divider',
getDisplayText: (intl) => intl.formatMessage({id: 'ContentBlock.divider', defaultMessage: 'divider'}),
getIcon: () => <DividerIcon/>,
createBlock: async () => {
return new MutableDividerBlock()
},
createComponent: () => <DividerElement/>,
})
export default DividerElement

View file

@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {IContentBlock} from '../../blocks/contentBlock'
import {MutableImageBlock} from '../../blocks/imageBlock'
import octoClient from '../../octoClient'
import {Utils} from '../../utils'
import ImageIcon from '../../widgets/icons/image'
import {contentRegistry} from './contentRegistry'
type Props = {
block: IContentBlock
intl: IntlShape
}
type State = {
imageDataUrl?: string
}
class ImageElement extends React.PureComponent<Props> {
state: State = {}
componentDidMount(): void {
if (!this.state.imageDataUrl) {
this.loadImage()
}
}
private async loadImage() {
const imageDataUrl = await octoClient.getFileAsDataUrl(this.props.block.fields.fileId)
this.setState({imageDataUrl})
}
public render(): JSX.Element | null {
const {block} = this.props
const {imageDataUrl} = this.state
if (!imageDataUrl) {
return null
}
return (
<img
src={imageDataUrl}
alt={block.title}
/>
)
}
}
contentRegistry.registerContentType({
type: 'image',
getDisplayText: (intl) => intl.formatMessage({id: 'ContentBlock.image', defaultMessage: 'image'}),
getIcon: () => <ImageIcon/>,
createBlock: async () => {
return new Promise<MutableImageBlock>(
(resolve) => {
Utils.selectLocalFile(async (file) => {
const fileId = await octoClient.uploadFile(file)
const block = new MutableImageBlock()
block.fileId = fileId || ''
resolve(block)
},
'.jpg,.jpeg,.png')
},
)
// return new MutableImageBlock()
},
createComponent: (block, intl) => {
return (
<ImageElement
block={block}
intl={intl}
/>
)
},
})
export default injectIntl(ImageElement)

View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {IContentBlock} from '../../blocks/contentBlock'
import {MutableTextBlock} from '../../blocks/textBlock'
import mutator from '../../mutator'
import TextIcon from '../../widgets/icons/text'
import {MarkdownEditor} from '../markdownEditor'
import {contentRegistry} from './contentRegistry'
type Props = {
block: IContentBlock
readonly: boolean
intl: IntlShape
}
class TextElement extends React.PureComponent<Props> {
render(): JSX.Element {
const {intl, block, readonly} = this.props
return (
<MarkdownEditor
text={block.title}
placeholderText={intl.formatMessage({id: 'ContentBlock.editText', defaultMessage: 'Edit text...'})}
onBlur={(text) => {
mutator.changeTitle(block, text, intl.formatMessage({id: 'ContentBlock.editCardText', defaultMessage: 'edit card text'}))
}}
readonly={readonly}
/>
)
}
}
contentRegistry.registerContentType({
type: 'text',
getDisplayText: (intl) => intl.formatMessage({id: 'ContentBlock.text', defaultMessage: 'text'}),
getIcon: () => <TextIcon/>,
createBlock: async () => {
return new MutableTextBlock()
},
createComponent: (block, intl, readonly) => {
return (
<TextElement
block={block}
intl={intl}
readonly={readonly}
/>
)
},
})
export default injectIntl(TextElement)

View file

@ -7,12 +7,6 @@
display: flex;
}
}
.divider {
padding-top: 16px;
border-bottom: 1px solid rgba(var(--body-color), 0.09);
margin-bottom: 17px;
flex-grow: 1;
}
> * {
flex: 1 1 auto;
}

View file

@ -4,27 +4,23 @@
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {BlockTypes} from '../blocks/block'
import {Card} from '../blocks/card'
import {IContentBlock} from '../blocks/contentBlock'
import {MutableDividerBlock} from '../blocks/dividerBlock'
import {MutableTextBlock} from '../blocks/textBlock'
import mutator from '../mutator'
import octoClient from '../octoClient'
import {Utils} from '../utils'
import IconButton from '../widgets/buttons/iconButton'
import AddIcon from '../widgets/icons/add'
import DeleteIcon from '../widgets/icons/delete'
import DividerIcon from '../widgets/icons/divider'
import ImageIcon from '../widgets/icons/image'
import OptionsIcon from '../widgets/icons/options'
import SortDownIcon from '../widgets/icons/sortDown'
import SortUpIcon from '../widgets/icons/sortUp'
import TextIcon from '../widgets/icons/text'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import ContentElement from './content/contentElement'
import {contentRegistry} from './content/contentRegistry'
import './contentBlock.scss'
import {MarkdownEditor} from './markdownEditor'
type Props = {
block: IContentBlock
@ -34,31 +30,9 @@ type Props = {
intl: IntlShape
}
type State = {
imageDataUrl?: string
}
class ContentBlock extends React.PureComponent<Props, State> {
state: State = {}
componentDidMount(): void {
if (this.props.block.type === 'image' && !this.state.imageDataUrl) {
this.loadImage()
}
}
private async loadImage() {
const imageDataUrl = await octoClient.getFileAsDataUrl(this.props.block.fields.fileId)
this.setState({imageDataUrl})
}
class ContentBlock extends React.PureComponent<Props> {
public render(): JSX.Element | null {
const {intl, card, contents, block} = this.props
if (block.type !== 'text' && block.type !== 'image' && block.type !== 'divider') {
Utils.assertFailure(`Block type is unknown: ${block.type}`)
return null
}
const {intl, card, contents, block, readonly} = this.props
const index = contents.indexOf(block)
return (
@ -95,61 +69,7 @@ class ContentBlock extends React.PureComponent<Props, State> {
name={intl.formatMessage({id: 'ContentBlock.insertAbove', defaultMessage: 'Insert above'})}
icon={<AddIcon/>}
>
<Menu.Text
id='text'
name={intl.formatMessage({id: 'ContentBlock.Text', defaultMessage: 'Text'})}
icon={<TextIcon/>}
onClick={() => {
const newBlock = new MutableTextBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
const contentOrder = contents.map((o) => o.id)
contentOrder.splice(index, 0, newBlock.id)
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'ContentBlock.addText', defaultMessage: 'add text'})
await mutator.insertBlock(newBlock, description)
await mutator.changeCardContentOrder(card, contentOrder, description)
})
}}
/>
<Menu.Text
id='image'
name='Image'
icon={<ImageIcon/>}
onClick={() => {
Utils.selectLocalFile((file) => {
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'ContentBlock.addImage', defaultMessage: 'add image'})
const newBlock = await mutator.createImageBlock(card, file, description)
if (newBlock) {
const contentOrder = contents.map((o) => o.id)
contentOrder.splice(index, 0, newBlock.id)
await mutator.changeCardContentOrder(card, contentOrder, description)
}
})
},
'.jpg,.jpeg,.png')
}}
/>
<Menu.Text
id='divider'
name={intl.formatMessage({id: 'ContentBlock.divider', defaultMessage: 'Divider'})}
icon={<DividerIcon/>}
onClick={() => {
const newBlock = new MutableDividerBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
const contentOrder = contents.map((o) => o.id)
contentOrder.splice(index, 0, newBlock.id)
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'ContentBlock.addDivider', defaultMessage: 'add divider'})
await mutator.insertBlock(newBlock, description)
await mutator.changeCardContentOrder(card, contentOrder, description)
})
}}
/>
{contentRegistry.contentTypes.map((type) => this.addContentMenu(type))}
</Menu.SubMenu>
<Menu.Text
icon={<DeleteIcon/>}
@ -168,24 +88,47 @@ class ContentBlock extends React.PureComponent<Props, State> {
</MenuWrapper>
}
</div>
{block.type === 'text' &&
<MarkdownEditor
text={block.title}
placeholderText={intl.formatMessage({id: 'ContentBlock.editText', defaultMessage: 'Edit text...'})}
onBlur={(text) => {
mutator.changeTitle(block, text, intl.formatMessage({id: 'ContentBlock.editCardText', defaultMessage: 'edit card text'}))
}}
readonly={this.props.readonly}
/>}
{block.type === 'divider' && <div className='divider'/>}
{block.type === 'image' && this.state.imageDataUrl &&
<img
src={this.state.imageDataUrl}
alt={block.title}
/>}
<ContentElement
block={block}
readonly={readonly}
/>
</div>
)
}
private addContentMenu(type: BlockTypes): JSX.Element {
const {intl, card, contents, block} = this.props
const index = contents.indexOf(block)
const handler = contentRegistry.getHandler(type)
if (!handler) {
Utils.logError(`addContentMenu, unknown content type: ${type}`)
return <></>
}
return (
<Menu.Text
key={type}
id={type}
name={handler.getDisplayText(intl)}
icon={handler.getIcon()}
onClick={async () => {
const newBlock = await handler.createBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
const contentOrder = contents.map((o) => o.id)
contentOrder.splice(index, 0, newBlock.id)
const typeName = handler.getDisplayText(intl)
const description = intl.formatMessage({id: 'ContentBlock.addElement', defaultMessage: 'add {type}'}, {type: typeName})
mutator.performAsUndoGroup(async () => {
await mutator.insertBlock(newBlock, description)
await mutator.changeCardContentOrder(card, contentOrder, description)
})
}}
/>
)
}
}
export default injectIntl(ContentBlock)

View file

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock} from '../blocks/block'
import {ContentBlockTypes, contentBlockTypes, IBlock} from '../blocks/block'
import {Card, MutableCard} from '../blocks/card'
import {CommentBlock} from '../blocks/commentBlock'
import {IContentBlock} from '../blocks/contentBlock'
@ -56,7 +56,7 @@ class MutableCardTree implements CardTree {
filter((block) => block.type === 'comment').
sort((a, b) => a.createAt - b.createAt) as CommentBlock[]
const contentBlocks = blocks.filter((block) => block.type === 'text' || block.type === 'image' || block.type === 'divider') as IContentBlock[]
const contentBlocks = blocks.filter((block) => contentBlockTypes.includes(block.type as ContentBlockTypes)) as IContentBlock[]
cardTree.contents = OctoUtils.getBlockOrder(card.contentOrder, contentBlocks)
return cardTree