Description Section of Card can now Have Columns (#637)

* Grid Layout

* add margin

* add margin

* more work

* fix linting

* fix alignment

* update viewing

* fix editing

* wip

* some wip fix

* fix stuff

* fix linting

* fix type errors

* fix render of image

* fixl inting

* fix tests

* fix linting

* fix tests

* fix eslint

* remove ref

* fix colIndex

* address PR comments

Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
This commit is contained in:
Hossein 2021-07-15 10:38:12 -04:00 committed by GitHub
parent d6f760b06b
commit 2ea4a85495
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 281 additions and 112 deletions

View file

@ -8,7 +8,7 @@ interface Card extends IBlock {
readonly icon: string
readonly isTemplate: boolean
readonly properties: Readonly<Record<string, string | string[]>>
readonly contentOrder: readonly string[]
readonly contentOrder: Readonly<Array<string | string[]>>
duplicate(): MutableCard
}
@ -35,10 +35,10 @@ class MutableCard extends MutableBlock implements Card {
this.fields.properties = value
}
get contentOrder(): string[] {
get contentOrder(): Array<string | string[]> {
return this.fields.contentOrder
}
set contentOrder(value: string[]) {
set contentOrder(value: Array<string | string[]>) {
this.fields.contentOrder = value
}

View file

@ -3,8 +3,9 @@
import {IBlock, MutableBlock} from './block'
type IContentBlock = IBlock
type IContentBlockWithCords = {block: IBlock, cords: {x: number, y?: number, z?: number}}
class MutableContentBlock extends MutableBlock implements IContentBlock {
}
export {IContentBlock, MutableContentBlock}
export {IContentBlock, IContentBlockWithCords, MutableContentBlock}

View file

@ -6,7 +6,6 @@ import {useIntl} from 'react-intl'
import {BlockTypes} from '../blocks/block'
import {Card} from '../blocks/card'
import {IContentBlock} from '../blocks/contentBlock'
import mutator from '../mutator'
import {Utils} from '../utils'
import Menu from '../widgets/menu'
@ -15,14 +14,14 @@ import {contentRegistry} from './content/contentRegistry'
type Props = {
type: BlockTypes
block: IContentBlock
card: Card
contents: readonly IContentBlock[]
cords: {x: number, y?: number, z?: number}
}
const AddContentMenuItem = React.memo((props:Props): JSX.Element => {
const {card, contents, block, type} = props
const index = contents.indexOf(block)
const {card, type, cords} = props
const index = cords.x
const contentOrder = card.contentOrder.slice()
const intl = useIntl()
const handler = contentRegistry.getHandler(type)
@ -42,7 +41,6 @@ const AddContentMenuItem = React.memo((props:Props): JSX.Element => {
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})

View file

@ -3,15 +3,18 @@
import React from 'react'
import {useIntl, IntlShape} from 'react-intl'
import {IContentBlock} from '../../blocks/contentBlock'
import {IContentBlockWithCords, IContentBlock} from '../../blocks/contentBlock'
import {MutableTextBlock} from '../../blocks/textBlock'
import mutator from '../../mutator'
import {CardTree} from '../../viewModel/cardTree'
import {Card} from '../../blocks/card'
import {useSortableWithGrip} from '../../hooks/sortable'
import ContentBlock from '../contentBlock'
import {MarkdownEditor} from '../markdownEditor'
export type Position = 'left' | 'right' | 'above' | 'below' | 'aboveRow' | 'belowRow'
type Props = {
cardTree: CardTree
readonly: boolean
@ -32,15 +35,62 @@ function addTextBlock(card: Card, intl: IntlShape, text: string): void {
})
}
function moveBlock(card: Card, srcBlock: IContentBlock, dstBlock: IContentBlock, intl: IntlShape): void {
let contentOrder = card.contentOrder.slice()
const isDraggingDown = contentOrder.indexOf(srcBlock.id) <= contentOrder.indexOf(dstBlock.id)
contentOrder = contentOrder.filter((id) => srcBlock.id !== id)
let destIndex = contentOrder.indexOf(dstBlock.id)
if (isDraggingDown) {
destIndex += 1
function moveBlock(card: Card, srcBlock: IContentBlockWithCords, dstBlock: IContentBlockWithCords, intl: IntlShape, moveTo: Position): void {
const contentOrder = card.contentOrder.slice()
const srcBlockId = srcBlock.block.id
const dstBlockId = dstBlock.block.id
const srcBlockX = srcBlock.cords.x
let dstBlockX = dstBlock.cords.x
const srcBlockY = (srcBlock.cords.y || srcBlock.cords.y === 0) && (srcBlock.cords.y > -1) ? srcBlock.cords.y : -1
let dstBlockY = (dstBlock.cords.y || dstBlock.cords.y === 0) && (dstBlock.cords.y > -1) ? dstBlock.cords.y : -1
if (srcBlockId === dstBlockId) {
return
}
// Delete Src Block
if (srcBlockY > -1) {
(contentOrder[srcBlockX] as string[]).splice(srcBlockY, 1)
if (contentOrder[srcBlockX].length === 1) {
contentOrder.splice(srcBlockX, 1, contentOrder[srcBlockX][0])
}
} else {
contentOrder.splice(srcBlockX, 1)
if (dstBlockX > srcBlockX) {
dstBlockX -= 1
}
}
if (moveTo === 'right') {
if (dstBlockY > -1) {
if (dstBlockX === srcBlockX && dstBlockY > srcBlockY) {
dstBlockY -= 1
}
(contentOrder[dstBlockX] as string[]).splice(dstBlockY + 1, 0, srcBlockId)
} else {
contentOrder.splice(dstBlockX, 1, [dstBlockId, srcBlockId])
}
} else if (moveTo === 'left') {
if (dstBlockY > -1) {
if (dstBlockX === srcBlockX && dstBlockY > srcBlockY) {
dstBlockY -= 1
}
(contentOrder[dstBlockX] as string[]).splice(dstBlockY, 0, srcBlockId)
} else {
contentOrder.splice(dstBlockX, 1, [srcBlockId, dstBlockId])
}
} else if (moveTo === 'aboveRow') {
contentOrder.splice(dstBlockX, 0, srcBlockId)
} else if (moveTo === 'belowRow') {
contentOrder.splice(dstBlockX + 1, 0, srcBlockId)
}
contentOrder.splice(destIndex, 0, srcBlock.id)
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'CardDetail.moveContent', defaultMessage: 'move card content'})
@ -48,6 +98,82 @@ function moveBlock(card: Card, srcBlock: IContentBlock, dstBlock: IContentBlock,
})
}
type ContentBlockWithDragAndDropProps = {
block: IContentBlock | IContentBlock[],
x: number,
card: Card,
cardTree: CardTree,
intl: IntlShape,
readonly: boolean,
}
const ContentBlockWithDragAndDrop = (props: ContentBlockWithDragAndDropProps) => {
const [, isOver,, itemRef] = useSortableWithGrip('content', {block: props.block, cords: {x: props.x}}, true, (src, dst) => moveBlock(props.card, src, dst, props.intl, 'aboveRow'))
const [, isOver2,, itemRef2] = useSortableWithGrip('content', {block: props.block, cords: {x: props.x}}, true, (src, dst) => moveBlock(props.card, src, dst, props.intl, 'belowRow'))
if (Array.isArray(props.block)) {
return (
<div >
<div
ref={itemRef}
className={`addToRow ${isOver ? 'dragover' : ''}`}
style={{width: '94%', height: '10px', marginLeft: '48px'}}
/>
<div
style={{display: 'flex'}}
>
{props.block.map((b, y) => (
<ContentBlock
key={b.id}
block={b}
card={props.card}
readonly={props.readonly}
width={(1 / (props.block as IContentBlock[]).length) * 100}
onDrop={(src, dst, moveTo) => moveBlock(props.card, src, dst, props.intl, moveTo)}
cords={{x: props.x, y}}
/>
))}
</div>
{props.x === props.cardTree.contents.length - 1 && (
<div
ref={itemRef2}
className={`addToRow ${isOver2 ? 'dragover' : ''}`}
style={{width: '94%', height: '10px', marginLeft: '48px'}}
/>
)}
</div>
)
}
return (
<div>
<div
ref={itemRef}
className={`addToRow ${isOver ? 'dragover' : ''}`}
style={{width: '94%', height: '10px', marginLeft: '48px'}}
/>
<ContentBlock
key={props.block.id}
block={props.block}
card={props.card}
readonly={props.readonly}
onDrop={(src, dst, moveTo) => moveBlock(props.card, src, dst, props.intl, moveTo)}
cords={{x: props.x}}
/>
{props.x === props.cardTree.contents.length - 1 && (
<div
ref={itemRef2}
className={`addToRow ${isOver2 ? 'dragover' : ''}`}
style={{width: '94%', height: '10px', marginLeft: '48px'}}
/>
)}
</div>
)
}
const CardDetailContents = React.memo((props: Props) => {
const intl = useIntl()
const {cardTree} = props
@ -55,20 +181,22 @@ const CardDetailContents = React.memo((props: Props) => {
return null
}
const {card} = cardTree
if (cardTree.contents.length > 0) {
return (
<div className='octo-content'>
{cardTree.contents.map((block) => (
<ContentBlock
key={block.id}
block={block}
card={card}
contents={cardTree.contents}
readonly={props.readonly}
onDrop={(src, dst) => moveBlock(card, src, dst, intl)}
/>
))}
{cardTree.contents.map((block, x) =>
(
<ContentBlockWithDragAndDrop
key={x}
block={block}
x={x}
card={card}
cardTree={cardTree}
intl={intl}
readonly={props.readonly}
/>
),
)}
</div>
)
}

View file

@ -15,6 +15,7 @@
}
> * {
flex: 1 1 auto;
max-width: 100%;
}
> .octo-block-margin {
flex: 0 0 auto;
@ -23,3 +24,12 @@
pointer-events: none;
}
}
.rowContents {
display: flex;
width: 100%;
}
.addToRow {
width: 10px;
}

View file

@ -5,7 +5,7 @@ import React from 'react'
import {useIntl} from 'react-intl'
import {Card} from '../blocks/card'
import {IContentBlock} from '../blocks/contentBlock'
import {IContentBlock, IContentBlockWithCords} from '../blocks/contentBlock'
import mutator from '../mutator'
import {Utils} from '../utils'
import IconButton from '../widgets/buttons/iconButton'
@ -18,6 +18,7 @@ import GripIcon from '../widgets/icons/grip'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import {useSortableWithGrip} from '../hooks/sortable'
import {Position} from '../components/cardDetail/cardDetailContents'
import ContentElement from './content/contentElement'
import AddContentMenuItem from './addContentMenuItem'
@ -27,29 +28,35 @@ import './contentBlock.scss'
type Props = {
block: IContentBlock
card: Card
contents: readonly IContentBlock[]
readonly: boolean
onDrop: (srctBlock: IContentBlock, dstBlock: IContentBlock) => void
onDrop: (srctBlock: IContentBlockWithCords, dstBlock: IContentBlockWithCords, position: Position) => void
width?: number
cords: {x: number, y?: number, z?: number}
}
const ContentBlock = React.memo((props: Props): JSX.Element => {
const {card, contents, block, readonly} = props
const {card, block, readonly, cords} = props
const intl = useIntl()
const [isDragging, isOver, gripRef, itemRef] = useSortableWithGrip('content', block, true, props.onDrop)
const [, , gripRef, itemRef] = useSortableWithGrip('content', {block, cords}, true, () => {})
const [, isOver2,, itemRef2] = useSortableWithGrip('content', {block, cords}, true, (src, dst) => props.onDrop(src, dst, 'right'))
const [, isOver3,, itemRef3] = useSortableWithGrip('content', {block, cords}, true, (src, dst) => props.onDrop(src, dst, 'left'))
const index = contents.indexOf(block)
let className = 'ContentBlock octo-block'
if (isOver) {
className += ' dragover'
}
const index = cords.x
const colIndex = (cords.y || cords.y === 0) && cords.y > -1 ? cords.y : -1
const contentOrder = card.contentOrder.slice()
const className = 'ContentBlock octo-block'
return (
<div
className={className}
style={{opacity: isDragging ? 0.5 : 1}}
ref={itemRef}
className='rowContents'
style={{width: props.width + '%'}}
>
<div className='octo-block-margin'>
{!props.readonly &&
<div
ref={itemRef}
className={className}
>
<div className='octo-block-margin'>
{!props.readonly &&
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
@ -59,18 +66,16 @@ const ContentBlock = React.memo((props: Props): JSX.Element => {
name={intl.formatMessage({id: 'ContentBlock.moveUp', defaultMessage: 'Move up'})}
icon={<SortUpIcon/>}
onClick={() => {
const contentOrder = contents.map((o) => o.id)
Utils.arrayMove(contentOrder, index, index - 1)
mutator.changeCardContentOrder(card, contentOrder)
}}
/>}
{index < (contents.length - 1) &&
{index < (contentOrder.length - 1) &&
<Menu.Text
id='moveDown'
name={intl.formatMessage({id: 'ContentBlock.moveDown', defaultMessage: 'Move down'})}
icon={<SortDownIcon/>}
onClick={() => {
const contentOrder = contents.map((o) => o.id)
Utils.arrayMove(contentOrder, index, index + 1)
mutator.changeCardContentOrder(card, contentOrder)
}}
@ -84,9 +89,8 @@ const ContentBlock = React.memo((props: Props): JSX.Element => {
<AddContentMenuItem
key={type}
type={type}
block={block}
card={card}
contents={contents}
cords={cords}
/>
))}
</Menu.SubMenu>
@ -96,7 +100,18 @@ const ContentBlock = React.memo((props: Props): JSX.Element => {
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)
if (colIndex > -1) {
(contentOrder[index] as string[]).splice(colIndex, 1)
} else {
contentOrder.splice(index, 1)
}
// If only one item in the row, convert form an array item to normal item ( [item] => item )
if (Array.isArray(contentOrder[index]) && contentOrder[index].length === 1) {
contentOrder[index] = contentOrder[index][0]
}
mutator.performAsUndoGroup(async () => {
await mutator.deleteBlock(block, description)
await mutator.changeCardContentOrder(card, contentOrder, description)
@ -105,19 +120,31 @@ const ContentBlock = React.memo((props: Props): JSX.Element => {
/>
</Menu>
</MenuWrapper>
}
{!props.readonly &&
}
{!props.readonly &&
<div
ref={gripRef}
className='dnd-handle'
>
<GripIcon/>
</div>
}
</div>
{!cords.y /* That is to say if cords.y === 0 or cords.y === undefined */ &&
<div
ref={gripRef}
className='dnd-handle'
>
<GripIcon/>
</div>
ref={itemRef3}
className={`addToRow ${isOver3 ? 'dragover' : ''}`}
style={{flex: 'none', height: '100%'}}
/>
}
<ContentElement
block={block}
readonly={readonly}
/>
</div>
<ContentElement
block={block}
readonly={readonly}
<div
ref={itemRef2}
className={`addToRow ${isOver2 ? 'dragover' : ''}`}
/>
</div>
)

View file

@ -77,7 +77,7 @@
}
}
> .content.fullwidth {
padding: 10px 0 10px 0;
padding-left: 78px;
}
}
}

View file

@ -42,8 +42,19 @@ const GalleryCard = React.memo((props: Props) => {
const visiblePropertyTemplates = props.visiblePropertyTemplates || []
let images: IContentBlock[] = []
images = cardTree.contents.filter((content) => content.type === 'image')
let image: IContentBlock | undefined
for (let i = 0; i < cardTree.contents.length; ++i) {
if (Array.isArray(cardTree.contents[i])) {
image = (cardTree.contents[i] as IContentBlock[]).find((c) => c.type === 'image')
} else if ((cardTree.contents[i] as IContentBlock).type === 'image') {
image = cardTree.contents[i] as IContentBlock
}
if (image) {
break
}
}
let className = props.isSelected ? 'GalleryCard selected' : 'GalleryCard'
if (isOver) {
className += ' dragover'
@ -81,19 +92,31 @@ const GalleryCard = React.memo((props: Props) => {
</MenuWrapper>
}
{images?.length > 0 &&
{image &&
<div className='gallery-image'>
<ImageElement block={images[0]}/>
<ImageElement block={image}/>
</div>}
{images?.length === 0 &&
{!image &&
<div className='gallery-item'>
{cardTree && images?.length === 0 && cardTree.contents.map((block) => (
<ContentElement
key={block.id}
block={block}
readonly={true}
/>
))}
{cardTree?.contents.map((block) => {
if (Array.isArray(block)) {
return block.map((b) => (
<ContentElement
key={b.id}
block={b}
readonly={true}
/>
))
}
return (
<ContentElement
key={block.id}
block={block}
readonly={true}
/>
)
})}
</div>}
{props.visibleTitle &&
<div className='gallery-title'>

View file

@ -13,6 +13,7 @@
pre.CodeMirror-line {
padding: 0;
word-break: break-word;
}
.CodeMirror,
@ -44,6 +45,7 @@
p {
margin: 0;
min-height: 32px;
word-break: break-word;
}
}

View file

@ -171,7 +171,7 @@ class Mutator {
await this.updateBlock(newBoard, board, actionDescription)
}
async changeCardContentOrder(card: Card, contentOrder: string[], description = 'reorder'): Promise<void> {
async changeCardContentOrder(card: Card, contentOrder: Array<string | string[]>, description = 'reorder'): Promise<void> {
const newCard = new MutableCard(card)
newCard.contentOrder = contentOrder
await this.updateBlock(newCard, card, description)

View file

@ -52,29 +52,6 @@ class OctoUtils {
return displayValue
}
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
}
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 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 {
switch (block.type) {
case 'board': { return new MutableBoard(block) }
@ -149,7 +126,7 @@ class OctoUtils {
// Remap card content order
if (newBlock.type === 'card') {
const card = newBlock as MutableCard
card.contentOrder = card.contentOrder.map((o) => idMap[o])
card.contentOrder = card.contentOrder.map((o) => (Array.isArray(o) ? o.map((o2) => idMap[o2]) : idMap[o]))
}
})

View file

@ -203,7 +203,6 @@ h1 {
.octo-content {
width: 100%;
margin-right: 50px;
}
.octo-block {
@ -213,9 +212,6 @@ h1 {
width: 100%;
@media not screen and (max-width: 975px) {
padding-right: 126px;
}
@media screen and (max-width: 975px) {
padding-right: 10px;
}
@ -233,10 +229,8 @@ h1 {
flex-direction: row;
align-items: flex-start;
justify-content: flex-end;
padding-top: 10px;
padding-right: 10px;
@media not screen and (max-width: 975px) {
width: 126px;
width: 48px;
}
}

View file

@ -57,8 +57,9 @@ test('CardTree', async () => {
const image2 = TestBlockFactory.createImage(card)
await Utils.sleep(10)
const divider2 = TestBlockFactory.createDivider(card)
card.contentOrder.push(...[text2.id, image2.id, divider2.id])
cardTree = MutableCardTree.incrementalUpdate(cardTree, [comment2, text2, image2, divider2])
cardTree = MutableCardTree.incrementalUpdate(cardTree, [card, comment2, text2, image2, divider2])
expect(cardTree).not.toBeUndefined()
if (!cardTree) {
fail('incrementalUpdate')
@ -79,6 +80,7 @@ test('CardTree', async () => {
fail('incrementalUpdate')
}
card.contentOrder = [text.id, image.id, divider.id]
FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify([card, comment, text, image, divider])))
cardTree = await MutableCardTree.sync(card.id)
expect(cardTree).not.toBeUndefined()

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react'
import {ContentBlockTypes, contentBlockTypes, IBlock} from '../blocks/block'
import {ContentBlockTypes, contentBlockTypes, IBlock, MutableBlock} from '../blocks/block'
import {Card, MutableCard} from '../blocks/card'
import {CommentBlock} from '../blocks/commentBlock'
import {IContentBlock} from '../blocks/contentBlock'
@ -12,7 +12,7 @@ import {OctoUtils} from '../octoUtils'
interface CardTree {
readonly card: Card
readonly comments: readonly CommentBlock[]
readonly contents: readonly IContentBlock[]
readonly contents: Readonly<Array< IContentBlock |IContentBlock[] >>
readonly allBlocks: readonly IBlock[]
readonly latestBlock: IBlock
}
@ -20,11 +20,11 @@ interface CardTree {
class MutableCardTree implements CardTree {
card: MutableCard
comments: CommentBlock[] = []
contents: IContentBlock[] = []
contents: (IContentBlock[] | IContentBlock)[] = []
latestBlock: IBlock
get allBlocks(): IBlock[] {
return [this.card, ...this.comments, ...this.contents]
return [this.card, ...this.comments, ...this.contents.flat()]
}
constructor(card: MutableCard) {
@ -62,7 +62,14 @@ class MutableCardTree implements CardTree {
sort((a, b) => a.createAt - b.createAt) as CommentBlock[]
const contentBlocks = blocks.filter((block) => contentBlockTypes.includes(block.type as ContentBlockTypes)) as IContentBlock[]
cardTree.contents = OctoUtils.getBlockOrder(card.contentOrder, contentBlocks)
cardTree.contents = card.contentOrder.map((contentIds) => {
if (Array.isArray(contentIds)) {
return contentIds.map((contentId) => contentBlocks.find((content) => content.id === contentId)).filter((content): content is IContentBlock => Boolean(content))
}
return contentBlocks.find((content) => content.id === contentIds) || new MutableBlock()
})
cardTree.latestBlock = MutableCardTree.getMostRecentBlock(cardTree)