Refactor: card contentOrder

This commit is contained in:
Chen-I Lim 2020-12-18 12:52:45 -08:00
parent f22527e650
commit 68f5130098
17 changed files with 186 additions and 126 deletions

View file

@ -58,7 +58,8 @@
}
],
"react/no-string-refs": 2,
"no-only-tests/no-only-tests": ["error", {"focus": ["only", "skip"]}]
"no-only-tests/no-only-tests": ["error", {"focus": ["only", "skip"]}],
"max-nested-callbacks": ["error", {"max": 5}]
},
"overrides": [
{

View file

@ -13,6 +13,7 @@
"CardDetail.add-content": "Add content",
"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",
@ -20,6 +21,18 @@
"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.insertAbove": "Insert above",
"ContentBlock.moveDown": "Move down",
"ContentBlock.moveUp": "Move up",
"Filter.includes": "includes",
"Filter.is-empty": "is empty",
"Filter.is-not-empty": "is not empty",

View file

@ -63,7 +63,6 @@ test('block: clone text', async () => {
const blockB = new MutableTextBlock(blockA)
expect(blockB).toEqual(blockA)
expect(blockB.order).toEqual(blockA.order)
})
test('block: clone image', async () => {
@ -72,7 +71,6 @@ test('block: clone image', async () => {
const blockB = new MutableImageBlock(blockA)
expect(blockB).toEqual(blockA)
expect(blockB.order).toEqual(blockA.order)
expect(blockB.url.length).toBeGreaterThan(0)
expect(blockB.url).toEqual(blockA.url)
})
@ -83,5 +81,4 @@ test('block: clone divider', async () => {
const blockB = new MutableDividerBlock(blockA)
expect(blockB).toEqual(blockA)
expect(blockB.order).toEqual(blockA.order)
})

View file

@ -9,6 +9,8 @@ interface Card extends IBlock {
readonly icon: string
readonly isTemplate: boolean
readonly properties: Readonly<Record<string, string>>
readonly contentOrder: readonly string[]
duplicate(): MutableCard
}
@ -34,12 +36,20 @@ class MutableCard extends MutableBlock {
this.fields.properties = value
}
get contentOrder(): string[] {
return this.fields.contentOrder
}
set contentOrder(value: string[]) {
this.fields.contentOrder = value
}
constructor(block: any = {}) {
super(block)
this.type = 'card'
this.icon = block.fields?.icon || ''
this.properties = {...(block.fields?.properties || {})}
this.contentOrder = block.fields?.contentOrder?.slice() || []
}
duplicate(): MutableCard {

View file

@ -0,0 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock, MutableBlock} from './block'
type IContentBlock = IBlock
class MutableContentBlock extends MutableBlock implements IContentBlock {
}
export {IContentBlock, MutableContentBlock}

View file

@ -1,10 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IOrderedBlock, MutableOrderedBlock} from './orderedBlock'
import {IContentBlock, MutableContentBlock} from './contentBlock'
type DividerBlock = IOrderedBlock
type DividerBlock = IContentBlock
class MutableDividerBlock extends MutableOrderedBlock {
class MutableDividerBlock extends MutableContentBlock {
constructor(block: any = {}) {
super(block)
this.type = 'divider'

View file

@ -1,12 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IOrderedBlock, MutableOrderedBlock} from './orderedBlock'
import {IContentBlock, MutableContentBlock} from './contentBlock'
interface ImageBlock extends IOrderedBlock {
interface ImageBlock extends IContentBlock {
readonly url: string
}
class MutableImageBlock extends MutableOrderedBlock implements IOrderedBlock {
class MutableImageBlock extends MutableContentBlock implements IContentBlock {
get url(): string {
return this.fields.url as string
}

View file

@ -1,25 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock} from '../blocks/block'
import {MutableBlock} from './block'
interface IOrderedBlock extends IBlock {
readonly order: number
}
class MutableOrderedBlock extends MutableBlock implements IOrderedBlock {
get order(): number {
return this.fields.order as number
}
set order(value: number) {
this.fields.order = value
}
constructor(block: any = {}) {
super(block)
this.order = block.fields?.order || 0
}
}
export {IOrderedBlock, MutableOrderedBlock}

View file

@ -1,10 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IOrderedBlock, MutableOrderedBlock} from './orderedBlock'
import {IContentBlock, MutableContentBlock} from './contentBlock'
type TextBlock = IOrderedBlock
type TextBlock = IContentBlock
class MutableTextBlock extends MutableOrderedBlock {
class MutableTextBlock extends MutableContentBlock {
constructor(block: any = {}) {
super(block)
this.type = 'text'

View file

@ -89,12 +89,7 @@ class CardDetail extends React.Component<Props, State> {
placeholderText='Add a description...'
onBlur={(text) => {
if (text) {
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.title = text
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add card text')
this.addTextBlock(text)
}
}}
/>
@ -230,19 +225,24 @@ class CardDetail extends React.Component<Props, State> {
id='text'
name={intl.formatMessage({id: 'CardDetail.text', defaultMessage: 'Text'})}
onClick={() => {
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add text')
this.addTextBlock('')
}}
/>
<Menu.Text
id='image'
name={intl.formatMessage({id: 'CardDetail.image', defaultMessage: 'Image'})}
onClick={() => Utils.selectLocalFile(
(file) => mutator.createImageBlock(card, file, (this.props.cardTree.contents.length + 1) * 1000),
'.jpg,.jpeg,.png',
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',
)}
/>
@ -253,6 +253,24 @@ class CardDetail extends React.Component<Props, State> {
</>
)
}
private addTextBlock(text: string): void {
const {intl, cardTree} = this.props
const {card} = cardTree
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.title = text
const contentOrder = card.contentOrder.slice()
contentOrder.push(block.id)
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'CardDetail.addCardText', defaultMessage: 'add card text'})
await mutator.insertBlock(block, description)
await mutator.changeCardContentOrder(card, contentOrder, description)
})
}
}
export default injectIntl(CardDetail)

View file

@ -2,13 +2,13 @@
// See LICENSE.txt for license information.
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {IBlock} from '../blocks/block'
import {Card} from '../blocks/card'
import {IContentBlock} from '../blocks/contentBlock'
import {MutableDividerBlock} from '../blocks/dividerBlock'
import {IOrderedBlock} from '../blocks/orderedBlock'
import {MutableTextBlock} from '../blocks/textBlock'
import mutator from '../mutator'
import {OctoUtils} from '../octoUtils'
import {Utils} from '../utils'
import IconButton from '../widgets/buttons/iconButton'
import AddIcon from '../widgets/icons/add'
@ -26,15 +26,16 @@ import './contentBlock.scss'
import {MarkdownEditor} from './markdownEditor'
type Props = {
block: IOrderedBlock
card: IBlock
contents: readonly IOrderedBlock[]
block: IContentBlock
card: Card
contents: readonly IContentBlock[]
readonly: boolean
intl: IntlShape
}
class ContentBlock extends React.PureComponent<Props> {
public render(): JSX.Element | null {
const {card, contents, block} = this.props
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}`)
@ -52,45 +53,46 @@ class ContentBlock extends React.PureComponent<Props> {
{index > 0 &&
<Menu.Text
id='moveUp'
name='Move up'
name={intl.formatMessage({id: 'ContentBlock.moveUp', defaultMessage: 'Move up'})}
icon={<SortUpIcon/>}
onClick={() => {
const previousBlock = contents[index - 1]
const newOrder = OctoUtils.getOrderBefore(previousBlock, contents)
Utils.log(`moveUp ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move up')
const contentOrder = contents.map((o) => o.id)
Utils.arrayMove(contentOrder, index, index - 1)
mutator.changeCardContentOrder(card, contentOrder)
}}
/>}
{index < (contents.length - 1) &&
<Menu.Text
id='moveDown'
name='Move down'
name={intl.formatMessage({id: 'ContentBlock.moveDown', defaultMessage: 'Move down'})}
icon={<SortDownIcon/>}
onClick={() => {
const nextBlock = contents[index + 1]
const newOrder = OctoUtils.getOrderAfter(nextBlock, contents)
Utils.log(`moveDown ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move down')
const contentOrder = contents.map((o) => o.id)
Utils.arrayMove(contentOrder, index, index + 1)
mutator.changeCardContentOrder(card, contentOrder)
}}
/>}
<Menu.SubMenu
id='insertAbove'
name='Insert above'
name={intl.formatMessage({id: 'ContentBlock.insertAbove', defaultMessage: 'Insert above'})}
icon={<AddIcon/>}
>
<Menu.Text
id='text'
name='Text'
name={intl.formatMessage({id: 'ContentBlock.Text', defaultMessage: 'Text'})}
icon={<TextIcon/>}
onClick={() => {
const newBlock = new MutableTextBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text')
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
@ -98,34 +100,51 @@ class ContentBlock extends React.PureComponent<Props> {
name='Image'
icon={<ImageIcon/>}
onClick={() => {
Utils.selectLocalFile(
(file) => {
mutator.createImageBlock(card, file, OctoUtils.getOrderBefore(block, contents))
},
'.jpg,.jpeg,.png')
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='Divider'
name={intl.formatMessage({id: 'ContentBlock.divider', defaultMessage: 'Divider'})}
icon={<DividerIcon/>}
onClick={() => {
const newBlock = new MutableDividerBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text')
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)
})
}}
/>
</Menu.SubMenu>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name='Delete'
onClick={() => mutator.deleteBlock(block)}
name={intl.formatMessage({id: 'ContentBlock.Delete', defaultMessage: 'Delete'})}
onClick={() => {
const description = intl.formatMessage({id: 'ContentBlock.DeleteAction', defaultMessage: 'delete'})
const contentOrder = contents.map((o) => o.id).filter((o) => o !== block.id)
mutator.performAsUndoGroup(async () => {
await mutator.deleteBlock(block, description)
await mutator.changeCardContentOrder(card, contentOrder, description)
})
}}
/>
</Menu>
</MenuWrapper>
@ -134,10 +153,9 @@ class ContentBlock extends React.PureComponent<Props> {
{block.type === 'text' &&
<MarkdownEditor
text={block.title}
placeholderText='Edit text...'
placeholderText={intl.formatMessage({id: 'ContentBlock.editText', defaultMessage: 'Edit text...'})}
onBlur={(text) => {
Utils.log(`change text ${block.id}, ${text}`)
mutator.changeTitle(block, text, 'edit card text')
mutator.changeTitle(block, text, intl.formatMessage({id: 'ContentBlock.editCardText', defaultMessage: 'edit card text'}))
}}
readonly={this.props.readonly}
/>}
@ -152,4 +170,4 @@ class ContentBlock extends React.PureComponent<Props> {
}
}
export default ContentBlock
export default injectIntl(ContentBlock)

View file

@ -1,18 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BlockIcons} from './blockIcons'
import {IBlock, MutableBlock} from './blocks/block'
import {Board, IPropertyOption, IPropertyTemplate, MutableBoard, PropertyType} from './blocks/board'
import {BoardView, ISortOption, MutableBoardView} from './blocks/boardView'
import {Card, MutableCard} from './blocks/card'
import {MutableImageBlock} from './blocks/imageBlock'
import {IOrderedBlock, MutableOrderedBlock} from './blocks/orderedBlock'
import {BoardTree} from './viewModel/boardTree'
import {FilterGroup} from './filterGroup'
import octoClient from './octoClient'
import {OctoUtils} from './octoUtils'
import undoManager from './undomanager'
import {Utils} from './utils'
import {OctoUtils} from './octoUtils'
import {BlockIcons} from './blockIcons'
import {BoardTree} from './viewModel/boardTree'
//
// The Mutator is used to make all changes to server state
@ -173,10 +172,10 @@ class Mutator {
await this.updateBlock(newBoard, board, actionDescription)
}
async changeOrder(block: IOrderedBlock, order: number, description = 'change order') {
const newBlock = new MutableOrderedBlock(block)
newBlock.order = order
await this.updateBlock(newBlock, block, description)
async changeCardContentOrder(card: Card, contentOrder: string[], description = 'reorder'): Promise<void> {
const newCard = new MutableCard(card)
newCard.contentOrder = contentOrder
await this.updateBlock(newCard, card, description)
}
// Property Templates
@ -583,7 +582,7 @@ class Mutator {
return octoClient.importFullArchive(blocks)
}
async createImageBlock(parent: IBlock, file: File, order = 1000): Promise<IBlock | undefined> {
async createImageBlock(parent: IBlock, file: File, description = 'add image'): Promise<IBlock | undefined> {
const url = await octoClient.uploadFile(file)
if (!url) {
return undefined
@ -592,7 +591,6 @@ class Mutator {
const block = new MutableImageBlock()
block.parentId = parent.id
block.rootId = parent.rootId
block.order = order
block.url = url
await undoManager.perform(
@ -602,7 +600,7 @@ class Mutator {
async () => {
await octoClient.deleteBlock(block.id)
},
'add image',
description,
this.undoGroupId,
)

View file

@ -8,7 +8,6 @@ import {MutableCard} from './blocks/card'
import {MutableCommentBlock} from './blocks/commentBlock'
import {MutableDividerBlock} from './blocks/dividerBlock'
import {MutableImageBlock} from './blocks/imageBlock'
import {IOrderedBlock} from './blocks/orderedBlock'
import {MutableTextBlock} from './blocks/textBlock'
import {Utils} from './utils'
@ -42,22 +41,27 @@ class OctoUtils {
return displayValue
}
static getOrderBefore(block: IOrderedBlock, blocks: readonly IOrderedBlock[]): number {
const index = blocks.indexOf(block)
if (index === 0) {
return block.order / 2
static relativeBlockOrder(partialOrder: readonly string[], blocks: readonly IBlock[], blockA: IBlock, blockB: IBlock): number {
const orderA = partialOrder.indexOf(blockA.id)
const orderB = partialOrder.indexOf(blockB.id)
if (orderA >= 0 && orderB >= 0) {
// Order of both blocks is specified
return orderA - orderB
}
const previousBlock = blocks[index - 1]
return (block.order + previousBlock.order) / 2
if (orderA >= 0) {
return -1
}
if (orderB >= 0) {
return 1
}
// Order of both blocks are unspecified, use create date
return blockA.createAt - blockB.createAt
}
static getOrderAfter(block: IOrderedBlock, blocks: readonly IOrderedBlock[]): number {
const index = blocks.indexOf(block)
if (index === blocks.length - 1) {
return block.order + 1000
}
const nextBlock = blocks[index + 1]
return (block.order + nextBlock.order) / 2
static getBlockOrder(partialOrder: readonly string[], blocks: readonly IBlock[]): IBlock[] {
return blocks.slice().sort((a, b) => this.relativeBlockOrder(partialOrder, blocks, a, b))
}
static hydrateBlock(block: IBlock): MutableBlock {

View file

@ -99,7 +99,6 @@ class TestBlockFactory {
block.parentId = card.id
block.rootId = card.rootId
block.title = 'title'
block.order = 100
return block
}
@ -109,7 +108,6 @@ class TestBlockFactory {
block.parentId = card.id
block.rootId = card.rootId
block.url = 'url'
block.order = 100
return block
}
@ -119,7 +117,6 @@ class TestBlockFactory {
block.parentId = card.id
block.rootId = card.rootId
block.title = 'title'
block.order = 100
return block
}

View file

@ -76,6 +76,10 @@ class Utils {
return text
}
static sleep(miliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, miliseconds))
}
// Errors
static assertValue(valueObject: any): void {
@ -200,6 +204,10 @@ class Utils {
}
return true
}
static arrayMove(arr: any[], srcIndex: number, destIndex: number): void {
arr.splice(destIndex, 0, arr.splice(srcIndex, 1)[0])
}
}
export {Utils}

View file

@ -8,6 +8,8 @@ import 'isomorphic-fetch'
import {TestBlockFactory} from '../test/testBlockFactory'
import {FetchMock} from '../test/fetchMock'
import {Utils} from '../utils'
import {CardTree, MutableCardTree} from './cardTree'
global.fetch = FetchMock.fn
@ -20,9 +22,14 @@ test('CardTree', async () => {
const card = TestBlockFactory.createCard()
expect(card.id).not.toBeNull()
const comment = TestBlockFactory.createComment(card)
// Content
const text = TestBlockFactory.createText(card)
await Utils.sleep(10)
const image = TestBlockFactory.createImage(card)
await Utils.sleep(10)
const divider = TestBlockFactory.createDivider(card)
card.contentOrder = [image.id, divider.id, text.id]
let cardTree: CardTree | undefined
@ -41,12 +48,14 @@ test('CardTree', async () => {
expect(FetchMock.fn).toBeCalledTimes(2)
expect(cardTree.card).toEqual(card)
expect(cardTree.comments).toEqual([comment])
expect(cardTree.contents).toEqual([text, image, divider])
expect(cardTree.contents).toEqual([image, divider, text]) // Must match specified card.contentOrder
// Incremental update
const comment2 = TestBlockFactory.createComment(card)
const text2 = TestBlockFactory.createText(card)
await Utils.sleep(10)
const image2 = TestBlockFactory.createImage(card)
await Utils.sleep(10)
const divider2 = TestBlockFactory.createDivider(card)
cardTree = MutableCardTree.incrementalUpdate(cardTree, [comment2, text2, image2, divider2])
@ -55,7 +64,9 @@ test('CardTree', async () => {
fail('incrementalUpdate')
}
expect(cardTree.comments).toEqual([comment, comment2])
expect(cardTree.contents).toEqual([text, image, divider, text2, image2, divider2])
// The added content's order was not specified in card.contentOrder, so much match created date order
expect(cardTree.contents).toEqual([image, divider, text, text2, image2, divider2])
// Incremental update: No change
const anotherCard = TestBlockFactory.createCard()

View file

@ -2,21 +2,21 @@
// See LICENSE.txt for license information.
import {IBlock} from '../blocks/block'
import {Card, MutableCard} from '../blocks/card'
import {IOrderedBlock} from '../blocks/orderedBlock'
import {IContentBlock} from '../blocks/contentBlock'
import octoClient from '../octoClient'
import {OctoUtils} from '../octoUtils'
interface CardTree {
readonly card: Card
readonly comments: readonly IBlock[]
readonly contents: readonly IOrderedBlock[]
readonly contents: readonly IContentBlock[]
readonly allBlocks: readonly IBlock[]
}
class MutableCardTree implements CardTree {
card: MutableCard
comments: IBlock[] = []
contents: IOrderedBlock[] = []
contents: IContentBlock[] = []
get allBlocks(): IBlock[] {
return [this.card, ...this.comments, ...this.contents]
@ -55,8 +55,8 @@ class MutableCardTree implements CardTree {
filter((block) => block.type === 'comment').
sort((a, b) => a.createAt - b.createAt)
const contentBlocks = blocks.filter((block) => block.type === 'text' || block.type === 'image' || block.type === 'divider') as IOrderedBlock[]
cardTree.contents = contentBlocks.sort((a, b) => a.order - b.order)
const contentBlocks = blocks.filter((block) => block.type === 'text' || block.type === 'image' || block.type === 'divider') as IContentBlock[]
cardTree.contents = OctoUtils.getBlockOrder(card.contentOrder, contentBlocks)
return cardTree
}