1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-06-03 22:27:30 +02:00

npm run fix and replaced tabs with spaces

This commit is contained in:
Chen-I Lim 2020-10-21 15:03:12 -07:00
parent 262f3c043d
commit a8a274ff0f
27 changed files with 999 additions and 932 deletions

View File

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import { IBlock } from '../blocks/block' import {IBlock} from '../blocks/block'
import {MutableBlock} from './block' import {MutableBlock} from './block'
type PropertyType = 'text' | 'number' | 'select' | 'multiSelect' | 'date' | 'person' | 'file' | 'checkbox' | 'url' | 'email' | 'phone' | 'createdTime' | 'createdBy' | 'updatedTime' | 'updatedBy' type PropertyType = 'text' | 'number' | 'select' | 'multiSelect' | 'date' | 'person' | 'file' | 'checkbox' | 'url' | 'email' | 'phone' | 'createdTime' | 'createdBy' | 'updatedTime' | 'updatedBy'

View File

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import { IBlock } from '../blocks/block' import {IBlock} from '../blocks/block'
import {FilterGroup} from '../filterGroup' import {FilterGroup} from '../filterGroup'
import {MutableBlock} from './block' import {MutableBlock} from './block'

View File

@ -1,10 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import { IBlock } from '../blocks/block' import {IBlock} from '../blocks/block'
import {MutableBlock} from './block' import {MutableBlock} from './block'
interface CommentBlock extends IBlock { type CommentBlock = IBlock
}
class MutableCommentBlock extends MutableBlock { class MutableCommentBlock extends MutableBlock {
constructor(block: any = {}) { constructor(block: any = {}) {

View File

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import { IOrderedBlock, MutableOrderedBlock } from './orderedBlock' import {IOrderedBlock, MutableOrderedBlock} from './orderedBlock'
interface ImageBlock extends IOrderedBlock { interface ImageBlock extends IOrderedBlock {
readonly url: string readonly url: string

View File

@ -1,5 +1,8 @@
import { IBlock } from "../blocks/block" // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
import { MutableBlock } from "./block" // See LICENSE.txt for license information.
import {IBlock} from '../blocks/block'
import {MutableBlock} from './block'
interface IOrderedBlock extends IBlock { interface IOrderedBlock extends IBlock {
readonly order: number readonly order: number

View File

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

View File

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react' import React from 'react'
import { MutableBlock } from '../blocks/block'
import {MutableBlock} from '../blocks/block'
import {IPropertyTemplate} from '../blocks/board' import {IPropertyTemplate} from '../blocks/board'
import {Card} from '../blocks/card' import {Card} from '../blocks/card'
@ -66,8 +67,8 @@ class BoardCard extends React.Component<BoardCardProps, BoardCardState> {
<div key='__title'>{card.title || 'Untitled'}</div> <div key='__title'>{card.title || 'Untitled'}</div>
</div> </div>
{visiblePropertyTemplates.map((template) => { {visiblePropertyTemplates.map((template) => {
return OctoUtils.propertyValueReadonlyElement(card, template, '') return OctoUtils.propertyValueReadonlyElement(card, template, '')
})} })}
</div>) </div>)
return element return element

View File

@ -1,24 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react' import React from 'react'
import { BlockIcons } from '../blockIcons'
import { MutableCommentBlock } from '../blocks/commentBlock' import {BlockIcons} from '../blockIcons'
import { IOrderedBlock } from '../blocks/orderedBlock' import {MutableCommentBlock} from '../blocks/commentBlock'
import { MutableTextBlock } from '../blocks/textBlock' import {IOrderedBlock} from '../blocks/orderedBlock'
import { BoardTree } from '../viewModel/boardTree' import {MutableTextBlock} from '../blocks/textBlock'
import { CardTree, MutableCardTree } from '../viewModel/cardTree' import {BoardTree} from '../viewModel/boardTree'
import { Menu as OldMenu, MenuOption } from '../menu' import {CardTree, MutableCardTree} from '../viewModel/cardTree'
import {Menu as OldMenu, MenuOption} from '../menu'
import mutator from '../mutator' import mutator from '../mutator'
import { OctoListener } from '../octoListener' import {OctoListener} from '../octoListener'
import { IBlock } from '../blocks/block' import {IBlock} from '../blocks/block'
import { OctoUtils } from '../octoUtils' import {OctoUtils} from '../octoUtils'
import { PropertyMenu } from '../propertyMenu' import {PropertyMenu} from '../propertyMenu'
import { Utils } from '../utils' import {Utils} from '../utils'
import Button from './button' import Button from './button'
import { Editable } from './editable' import {Editable} from './editable'
import { MarkdownEditor } from './markdownEditor' import {MarkdownEditor} from './markdownEditor'
type Props = { type Props = {
boardTree: BoardTree boardTree: BoardTree
@ -35,25 +35,25 @@ export default class CardDetail extends React.Component<Props, State> {
private cardListener?: OctoListener private cardListener?: OctoListener
constructor(props: Props) { constructor(props: Props) {
super(props) super(props)
this.state = {isHoverOnCover: false} this.state = {isHoverOnCover: false}
} }
componentDidMount() { componentDidMount() {
this.cardListener = new OctoListener() this.cardListener = new OctoListener()
this.cardListener.open([this.props.cardId], async (blockId) => { this.cardListener.open([this.props.cardId], async (blockId) => {
Utils.log(`cardListener.onChanged: ${blockId}`) Utils.log(`cardListener.onChanged: ${blockId}`)
await cardTree.sync() await cardTree.sync()
this.setState({...this.state, cardTree}) this.setState({...this.state, cardTree})
}) })
const cardTree = new MutableCardTree(this.props.cardId) const cardTree = new MutableCardTree(this.props.cardId)
cardTree.sync().then(() => { cardTree.sync().then(() => {
this.setState({...this.state, cardTree}) this.setState({...this.state, cardTree})
setTimeout(() => { setTimeout(() => {
if (this.titleRef.current) { if (this.titleRef.current) {
this.titleRef.current.focus() this.titleRef.current.focus()
} }
}, 0) }, 0)
}) })
} }
@ -63,17 +63,17 @@ export default class CardDetail extends React.Component<Props, State> {
} }
render() { render() {
const {boardTree} = this.props const {boardTree} = this.props
const {cardTree} = this.state const {cardTree} = this.state
const {board} = boardTree const {board} = boardTree
if (!cardTree) { if (!cardTree) {
return null return null
} }
const {card, comments} = cardTree const {card, comments} = cardTree
const newCommentPlaceholderText = 'Add a comment...' const newCommentPlaceholderText = 'Add a comment...'
const backgroundRef = React.createRef<HTMLDivElement>() const backgroundRef = React.createRef<HTMLDivElement>()
const newCommentRef = React.createRef<Editable>() const newCommentRef = React.createRef<Editable>()
const sendCommentButtonRef = React.createRef<HTMLDivElement>() const sendCommentButtonRef = React.createRef<HTMLDivElement>()
let contentElements let contentElements
@ -81,14 +81,14 @@ export default class CardDetail extends React.Component<Props, State> {
contentElements = contentElements =
(<div className='octo-content'> (<div className='octo-content'>
{cardTree.contents.map((block) => { {cardTree.contents.map((block) => {
if (block.type === 'text') { if (block.type === 'text') {
const cardText = block.title const cardText = block.title
return (<div return (<div
key={block.id} key={block.id}
className='octo-block octo-hover-container' className='octo-block octo-hover-container'
> >
<div className='octo-block-margin'> <div className='octo-block-margin'>
<div <div
className='octo-button octo-hovercontrol square octo-hover-item' className='octo-button octo-hovercontrol square octo-hover-item'
onClick={(e) => { onClick={(e) => {
this.showContentBlockMenu(e, block) this.showContentBlockMenu(e, block)
@ -96,22 +96,24 @@ export default class CardDetail extends React.Component<Props, State> {
> >
<div className='imageOptions'/> <div className='imageOptions'/>
</div> </div>
</div> </div>
<MarkdownEditor <MarkdownEditor
text={cardText} placeholderText='Edit text...' onChanged={(text) => { text={cardText}
placeholderText='Edit text...'
onChanged={(text) => {
Utils.log(`change text ${block.id}, ${text}`) Utils.log(`change text ${block.id}, ${text}`)
mutator.changeTitle(block, text, 'edit card text') mutator.changeTitle(block, text, 'edit card text')
}} }}
/> />
</div>) </div>)
} else if (block.type === 'image') { } else if (block.type === 'image') {
const url = block.fields.url const url = block.fields.url
return (<div return (<div
key={block.id} key={block.id}
className='octo-block octo-hover-container' className='octo-block octo-hover-container'
> >
<div className='octo-block-margin'> <div className='octo-block-margin'>
<div <div
className='octo-button octo-hovercontrol square octo-hover-item' className='octo-button octo-hovercontrol square octo-hover-item'
onClick={(e) => { onClick={(e) => {
this.showContentBlockMenu(e, block) this.showContentBlockMenu(e, block)
@ -119,18 +121,18 @@ export default class CardDetail extends React.Component<Props, State> {
> >
<div className='imageOptions'/> <div className='imageOptions'/>
</div> </div>
</div> </div>
<img <img
src={url} src={url}
alt={block.title} alt={block.title}
></img> />
</div>) </div>)
} }
return <div/> return <div/>
})} })}
</div>) </div>)
} else { } else {
contentElements = (<div className='octo-content'> contentElements = (<div className='octo-content'>
<div className='octo-block octo-hover-container'> <div className='octo-block octo-hover-container'>
<div className='octo-block-margin'/> <div className='octo-block-margin'/>
@ -147,58 +149,58 @@ export default class CardDetail extends React.Component<Props, State> {
/> />
</div> </div>
</div>) </div>)
} }
const icon = card.icon const icon = card.icon
// TODO: Replace this placeholder // TODO: Replace this placeholder
const username = 'John Smith' 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>' 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>'
return ( return (
<> <>
<div className='content'> <div className='content'>
{icon ? {icon ?
<div <div
className='octo-button octo-icon octo-card-icon' className='octo-button octo-icon octo-card-icon'
onClick={(e) => { onClick={(e) => {
this.iconClicked(e) this.iconClicked(e)
}} }}
>{icon}</div> : >{icon}</div> :
undefined undefined
} }
<div <div
className='octo-hovercontrols' className='octo-hovercontrols'
onMouseOver={() => { onMouseOver={() => {
this.setState({...this.state, isHoverOnCover: true}) this.setState({...this.state, isHoverOnCover: true})
}} }}
onMouseLeave={() => { onMouseLeave={() => {
this.setState({...this.state, isHoverOnCover: false}) this.setState({...this.state, isHoverOnCover: false})
}} }}
> >
<Button <Button
style={{display: (!icon && this.state.isHoverOnCover) ? null : 'none'}} style={{display: (!icon && this.state.isHoverOnCover) ? null : 'none'}}
onClick={() => { onClick={() => {
const newIcon = BlockIcons.shared.randomIcon() const newIcon = BlockIcons.shared.randomIcon()
mutator.changeIcon(card, newIcon) mutator.changeIcon(card, newIcon)
}} }}
>Add Icon</Button> >Add Icon</Button>
</div> </div>
<Editable <Editable
ref={this.titleRef} ref={this.titleRef}
className='title' className='title'
text={card.title} text={card.title}
placeholderText='Untitled' placeholderText='Untitled'
onChanged={(text) => { onChanged={(text) => {
mutator.changeTitle(card, text) mutator.changeTitle(card, text)
}} }}
/> />
{/* Property list */} {/* Property list */}
<div className='octo-propertylist'> <div className='octo-propertylist'>
{board.cardProperties.map((propertyTemplate) => { {board.cardProperties.map((propertyTemplate) => {
return ( return (
<div <div
key={propertyTemplate.id} key={propertyTemplate.id}
@ -208,72 +210,72 @@ export default class CardDetail extends React.Component<Props, State> {
className='octo-button octo-propertyname' className='octo-button octo-propertyname'
onClick={(e) => { onClick={(e) => {
const menu = PropertyMenu.shared const menu = PropertyMenu.shared
menu.property = propertyTemplate menu.property = propertyTemplate
menu.onNameChanged = (propertyName) => { menu.onNameChanged = (propertyName) => {
Utils.log('menu.onNameChanged') Utils.log('menu.onNameChanged')
mutator.renameProperty(board, propertyTemplate.id, propertyName) mutator.renameProperty(board, propertyTemplate.id, propertyName)
} }
menu.onMenuClicked = async (command) => { menu.onMenuClicked = async (command) => {
switch (command) { switch (command) {
case 'type-text': case 'type-text':
await mutator.changePropertyType(board, propertyTemplate, 'text') await mutator.changePropertyType(board, propertyTemplate, 'text')
break
case 'type-number':
await mutator.changePropertyType(board, propertyTemplate, 'number')
break
case 'type-createdTime':
await mutator.changePropertyType(board, propertyTemplate, 'createdTime')
break break
case 'type-updatedTime': case 'type-number':
await mutator.changePropertyType(board, propertyTemplate, 'number')
break
case 'type-createdTime':
await mutator.changePropertyType(board, propertyTemplate, 'createdTime')
break
case 'type-updatedTime':
await mutator.changePropertyType(board, propertyTemplate, 'updatedTime') await mutator.changePropertyType(board, propertyTemplate, 'updatedTime')
break break
case 'type-select': case 'type-select':
await mutator.changePropertyType(board, propertyTemplate, 'select') await mutator.changePropertyType(board, propertyTemplate, 'select')
break break
case 'delete': case 'delete':
await mutator.deleteProperty(boardTree, propertyTemplate.id) await mutator.deleteProperty(boardTree, propertyTemplate.id)
break break
default: default:
Utils.assertFailure(`Unhandled menu id: ${command}`) Utils.assertFailure(`Unhandled menu id: ${command}`)
} }
} }
menu.showAtElement(e.target as HTMLElement) menu.showAtElement(e.target as HTMLElement)
}} }}
>{propertyTemplate.name}</div> >{propertyTemplate.name}</div>
{OctoUtils.propertyValueEditableElement(card, propertyTemplate)} {OctoUtils.propertyValueEditableElement(card, propertyTemplate)}
</div> </div>
) )
})} })}
<div <div
className='octo-button octo-propertyname' className='octo-button octo-propertyname'
style={{textAlign: 'left', width: '150px', color: 'rgba(55, 53, 37, 0.4)'}} style={{textAlign: 'left', width: '150px', color: 'rgba(55, 53, 37, 0.4)'}}
onClick={async () => { onClick={async () => {
// TODO: Show UI // TODO: Show UI
await mutator.insertPropertyTemplate(boardTree) await mutator.insertPropertyTemplate(boardTree)
}} }}
>+ Add a property</div> >+ Add a property</div>
</div> </div>
{/* Comments */} {/* Comments */}
<hr/> <hr/>
<div className='commentlist'> <div className='commentlist'>
{comments.map((comment) => { {comments.map((comment) => {
const optionsButtonRef = React.createRef<HTMLDivElement>() const optionsButtonRef = React.createRef<HTMLDivElement>()
const showCommentMenu = (e: React.MouseEvent, activeComment: IBlock) => { const showCommentMenu = (e: React.MouseEvent, activeComment: IBlock) => {
OldMenu.shared.options = [ OldMenu.shared.options = [
{id: 'delete', name: 'Delete'}, {id: 'delete', name: 'Delete'},
] ]
OldMenu.shared.onMenuClicked = (id) => { OldMenu.shared.onMenuClicked = (id) => {
switch (id) { switch (id) {
case 'delete': { case 'delete': {
mutator.deleteBlock(activeComment) mutator.deleteBlock(activeComment)
break break
} }
} }
} }
OldMenu.shared.showAtElement(e.target as HTMLElement) OldMenu.shared.showAtElement(e.target as HTMLElement)
} }
@ -305,91 +307,91 @@ export default class CardDetail extends React.Component<Props, State> {
</div> </div>
<div className='comment-text'>{comment.title}</div> <div className='comment-text'>{comment.title}</div>
</div>) </div>)
})} })}
{/* New comment */} {/* New comment */}
<div className='commentrow'> <div className='commentrow'>
<img <img
className='comment-avatar' className='comment-avatar'
src={userImageUrl} src={userImageUrl}
/> />
<Editable <Editable
ref={newCommentRef} ref={newCommentRef}
className='newcomment' className='newcomment'
placeholderText={newCommentPlaceholderText} placeholderText={newCommentPlaceholderText}
onChanged={(text) => { }} onChanged={(text) => { }}
onFocus={() => { onFocus={() => {
sendCommentButtonRef.current.style.display = null sendCommentButtonRef.current.style.display = null
}} }}
onBlur={() => { onBlur={() => {
if (!newCommentRef.current.text) { if (!newCommentRef.current.text) {
sendCommentButtonRef.current.style.display = 'none' sendCommentButtonRef.current.style.display = 'none'
}
}}
onKeyDown={(e) => {
if (e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
sendCommentButtonRef.current.click()
} }
}} }}
/> onKeyDown={(e) => {
if (e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
sendCommentButtonRef.current.click()
}
}}
/>
<div <div
ref={sendCommentButtonRef} ref={sendCommentButtonRef}
className='octo-button filled' className='octo-button filled'
style={{display: 'none'}} style={{display: 'none'}}
onClick={(e) => { onClick={(e) => {
const text = newCommentRef.current.text const text = newCommentRef.current.text
console.log(`Send comment: ${newCommentRef.current.text}`) console.log(`Send comment: ${newCommentRef.current.text}`)
this.sendComment(text) this.sendComment(text)
newCommentRef.current.text = undefined newCommentRef.current.text = undefined
newCommentRef.current.blur() newCommentRef.current.blur()
}} }}
>Send</div> >Send</div>
</div> </div>
</div> </div>
<hr/> <hr/>
</div> </div>
{/* Content blocks */} {/* Content blocks */}
<div className='content fullwidth'> <div className='content fullwidth'>
{contentElements} {contentElements}
</div> </div>
<div className='content'> <div className='content'>
<div className='octo-hoverpanel octo-hover-container'> <div className='octo-hoverpanel octo-hover-container'>
<div <div
className='octo-button octo-hovercontrol octo-hover-item' className='octo-button octo-hovercontrol octo-hover-item'
onClick={(e) => { onClick={(e) => {
OldMenu.shared.options = [ OldMenu.shared.options = [
{id: 'text', name: 'Text'}, {id: 'text', name: 'Text'},
{id: 'image', name: 'Image'}, {id: 'image', name: 'Image'},
] ]
OldMenu.shared.onMenuClicked = async (optionId: string, type?: string) => { OldMenu.shared.onMenuClicked = async (optionId: string, type?: string) => {
switch (optionId) { switch (optionId) {
case 'text': case 'text':
const block = new MutableTextBlock() const block = new MutableTextBlock()
block.parentId = card.id block.parentId = card.id
block.order = cardTree.contents.length * 1000 block.order = cardTree.contents.length * 1000
await mutator.insertBlock(block, 'add text') await mutator.insertBlock(block, 'add text')
break break
case 'image': case 'image':
Utils.selectLocalFile( Utils.selectLocalFile(
(file) => { (file) => {
mutator.createImageBlock(card.id, file, cardTree.contents.length * 1000) mutator.createImageBlock(card.id, file, cardTree.contents.length * 1000)
}, },
'.jpg,.jpeg,.png') '.jpg,.jpeg,.png')
break break
} }
} }
OldMenu.shared.showAtElement(e.target as HTMLElement) OldMenu.shared.showAtElement(e.target as HTMLElement)
}} }}
>Add content</div> >Add content</div>
</div> </div>
</div> </div>
</> </>
) )
} }
@ -398,56 +400,56 @@ export default class CardDetail extends React.Component<Props, State> {
Utils.assertValue(cardId) Utils.assertValue(cardId)
const block = new MutableCommentBlock({parentId: cardId, title: text}) const block = new MutableCommentBlock({parentId: cardId, title: text})
await mutator.insertBlock(block, 'add comment') await mutator.insertBlock(block, 'add comment')
} }
private showContentBlockMenu(e: React.MouseEvent, block: IOrderedBlock) { private showContentBlockMenu(e: React.MouseEvent, block: IOrderedBlock) {
const {cardTree} = this.state const {cardTree} = this.state
const {cardId} = this.props const {cardId} = this.props
const index = cardTree.contents.indexOf(block) const index = cardTree.contents.indexOf(block)
const options: MenuOption[] = [] const options: MenuOption[] = []
if (index > 0) { if (index > 0) {
options.push({id: 'moveUp', name: 'Move up'}) options.push({id: 'moveUp', name: 'Move up'})
} }
if (index < cardTree.contents.length - 1) { if (index < cardTree.contents.length - 1) {
options.push({id: 'moveDown', name: 'Move down'}) options.push({id: 'moveDown', name: 'Move down'})
} }
options.push( options.push(
{id: 'insertAbove', name: 'Insert above', type: 'submenu'}, {id: 'insertAbove', name: 'Insert above', type: 'submenu'},
{id: 'delete', name: 'Delete'}, {id: 'delete', name: 'Delete'},
) )
OldMenu.shared.options = options OldMenu.shared.options = options
OldMenu.shared.subMenuOptions.set('insertAbove', [ OldMenu.shared.subMenuOptions.set('insertAbove', [
{id: 'text', name: 'Text'}, {id: 'text', name: 'Text'},
{id: 'image', name: 'Image'}, {id: 'image', name: 'Image'},
]) ])
OldMenu.shared.onMenuClicked = (optionId: string, type?: string) => { OldMenu.shared.onMenuClicked = (optionId: string, type?: string) => {
switch (optionId) { switch (optionId) {
case 'moveUp': { case 'moveUp': {
if (index < 1) { if (index < 1) {
Utils.logError(`Unexpected index ${index}`); return Utils.logError(`Unexpected index ${index}`); return
} }
const previousBlock = cardTree.contents[index - 1] const previousBlock = cardTree.contents[index - 1]
const newOrder = OctoUtils.getOrderBefore(previousBlock, cardTree.contents) const newOrder = OctoUtils.getOrderBefore(previousBlock, cardTree.contents)
Utils.log(`moveUp ${newOrder}`) Utils.log(`moveUp ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move up') mutator.changeOrder(block, newOrder, 'move up')
break break
} }
case 'moveDown': { case 'moveDown': {
if (index >= cardTree.contents.length - 1) { if (index >= cardTree.contents.length - 1) {
Utils.logError(`Unexpected index ${index}`); return Utils.logError(`Unexpected index ${index}`); return
} }
const nextBlock = cardTree.contents[index + 1] const nextBlock = cardTree.contents[index + 1]
const newOrder = OctoUtils.getOrderAfter(nextBlock, cardTree.contents) const newOrder = OctoUtils.getOrderAfter(nextBlock, cardTree.contents)
Utils.log(`moveDown ${newOrder}`) Utils.log(`moveDown ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move down') mutator.changeOrder(block, newOrder, 'move down')
break break
} }
case 'insertAbove-text': { case 'insertAbove-text': {
const newBlock = new MutableTextBlock() const newBlock = new MutableTextBlock()
newBlock.parentId = cardId newBlock.parentId = cardId
@ -455,24 +457,24 @@ export default class CardDetail extends React.Component<Props, State> {
newBlock.order = OctoUtils.getOrderBefore(block, cardTree.contents) newBlock.order = OctoUtils.getOrderBefore(block, cardTree.contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`) Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text') mutator.insertBlock(newBlock, 'insert card text')
break break
} }
case 'insertAbove-image': { case 'insertAbove-image': {
Utils.selectLocalFile( Utils.selectLocalFile(
(file) => { (file) => {
mutator.createImageBlock(cardId, file, OctoUtils.getOrderBefore(block, cardTree.contents)) mutator.createImageBlock(cardId, file, OctoUtils.getOrderBefore(block, cardTree.contents))
}, },
'.jpg,.jpeg,.png') '.jpg,.jpeg,.png')
break break
} }
case 'delete': { case 'delete': {
mutator.deleteBlock(block) mutator.deleteBlock(block)
break break
}
} }
} }
OldMenu.shared.showAtElement(e.target as HTMLElement) }
OldMenu.shared.showAtElement(e.target as HTMLElement)
} }
private iconClicked(e: React.MouseEvent) { private iconClicked(e: React.MouseEvent) {
@ -481,18 +483,18 @@ export default class CardDetail extends React.Component<Props, State> {
OldMenu.shared.options = [ OldMenu.shared.options = [
{id: 'random', name: 'Random'}, {id: 'random', name: 'Random'},
{id: 'remove', name: 'Remove Icon'}, {id: 'remove', name: 'Remove Icon'},
] ]
OldMenu.shared.onMenuClicked = (optionId: string, type?: string) => { OldMenu.shared.onMenuClicked = (optionId: string, type?: string) => {
switch (optionId) { switch (optionId) {
case 'remove': case 'remove':
mutator.changeIcon(card, undefined, 'remove icon') mutator.changeIcon(card, undefined, 'remove icon')
break break
case 'random': case 'random':
const newIcon = BlockIcons.shared.randomIcon() const newIcon = BlockIcons.shared.randomIcon()
mutator.changeIcon(card, newIcon) mutator.changeIcon(card, newIcon)
break break
} }
} }
OldMenu.shared.showAtElement(e.target as HTMLElement) OldMenu.shared.showAtElement(e.target as HTMLElement)
} }

View File

@ -24,7 +24,7 @@ type State = {
class Editable extends React.Component<Props, State> { class Editable extends React.Component<Props, State> {
static defaultProps = { static defaultProps = {
text: '', text: '',
isMarkdown: false, isMarkdown: false,
isMultiline: false, isMultiline: false,
} }
@ -36,30 +36,30 @@ class Editable extends React.Component<Props, State> {
const {isMarkdown} = this.props const {isMarkdown} = this.props
if (!value) { if (!value) {
this.elementRef.current.innerText = '' this.elementRef.current.innerText = ''
} else { } else {
this.elementRef.current.innerHTML = isMarkdown ? Utils.htmlFromMarkdown(value) : Utils.htmlEncode(value) this.elementRef.current.innerHTML = isMarkdown ? Utils.htmlFromMarkdown(value) : Utils.htmlEncode(value)
} }
this._text = value || '' this._text = value || ''
} }
private elementRef = React.createRef<HTMLDivElement>() private elementRef = React.createRef<HTMLDivElement>()
constructor(props: Props) { constructor(props: Props) {
super(props) super(props)
this._text = props.text || '' this._text = props.text || ''
} }
componentDidUpdate(prevPros: Props, prevState: State) { componentDidUpdate() {
this._text = this.props.text || '' this._text = this.props.text || ''
} }
focus() { focus() {
this.elementRef.current.focus() this.elementRef.current.focus()
// Put cursor at end // Put cursor at end
document.execCommand('selectAll', false, null) document.execCommand('selectAll', false, null)
document.getSelection().collapseToEnd() document.getSelection().collapseToEnd()
} }
@ -68,15 +68,15 @@ class Editable extends React.Component<Props, State> {
} }
render() { render() {
const {text, className, style, placeholderText, isMarkdown, isMultiline, onFocus, onBlur, onKeyDown, onChanged} = this.props const {text, className, style, placeholderText, isMarkdown, isMultiline, onFocus, onBlur, onKeyDown, onChanged} = this.props
const initialStyle = {...this.props.style} const initialStyle = {...this.props.style}
let html: string let html: string
if (text) { if (text) {
html = isMarkdown ? Utils.htmlFromMarkdown(text) : Utils.htmlEncode(text) html = isMarkdown ? Utils.htmlFromMarkdown(text) : Utils.htmlEncode(text)
} else { } else {
html = '' html = ''
} }
const element = const element =
@ -91,43 +91,43 @@ class Editable extends React.Component<Props, State> {
dangerouslySetInnerHTML={{__html: html}} dangerouslySetInnerHTML={{__html: html}}
onFocus={() => { onFocus={() => {
this.elementRef.current.innerText = this.text this.elementRef.current.innerText = this.text
this.elementRef.current.style.color = style?.color || null this.elementRef.current.style.color = style?.color || null
this.elementRef.current.classList.add('active') this.elementRef.current.classList.add('active')
if (onFocus) { if (onFocus) {
onFocus() onFocus()
} }
}} }}
onBlur={async () => { onBlur={async () => {
const newText = this.elementRef.current.innerText const newText = this.elementRef.current.innerText
const oldText = this.props.text || '' const oldText = this.props.text || ''
if (newText !== oldText && onChanged) { if (newText !== oldText && onChanged) {
onChanged(newText) onChanged(newText)
} }
this.text = newText this.text = newText
this.elementRef.current.classList.remove('active') this.elementRef.current.classList.remove('active')
if (onBlur) { if (onBlur) {
onBlur() onBlur()
} }
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC
e.stopPropagation() e.stopPropagation()
this.elementRef.current.blur() this.elementRef.current.blur()
} else if (!isMultiline && e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return } else if (!isMultiline && e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return
e.stopPropagation() e.stopPropagation()
this.elementRef.current.blur() this.elementRef.current.blur()
} }
if (onKeyDown) { if (onKeyDown) {
onKeyDown(e) onKeyDown(e)
} }
}} }}
/>); />);
return element return element

View File

@ -38,30 +38,30 @@ class MarkdownEditor extends React.Component<Props, State> {
private previewRef = React.createRef<HTMLDivElement>() private previewRef = React.createRef<HTMLDivElement>()
constructor(props: Props) { constructor(props: Props) {
super(props) super(props)
this.state = {isEditing: false} this.state = {isEditing: false}
} }
componentDidUpdate(prevPros: Props, prevState: State) { componentDidUpdate(prevPros: Props, prevState: State) {
this.text = this.props.text || '' this.text = this.props.text || ''
} }
showEditor() { showEditor() {
const cm = this.editorInstance?.codemirror const cm = this.editorInstance?.codemirror
if (cm) { if (cm) {
setTimeout(() => { setTimeout(() => {
cm.refresh() cm.refresh()
cm.focus() cm.focus()
cm.getInputField()?.focus() cm.getInputField()?.focus()
cm.setCursor(cm.lineCount(), 0) // Put cursor at end cm.setCursor(cm.lineCount(), 0) // Put cursor at end
}, 100) }, 100)
} }
this.setState({isEditing: true}) this.setState({isEditing: true})
} }
hideEditor() { hideEditor() {
this.editorInstance?.codemirror?.getInputField()?.blur() this.editorInstance?.codemirror?.getInputField()?.blur()
this.setState({isEditing: false}) this.setState({isEditing: false})
} }
@ -71,22 +71,22 @@ class MarkdownEditor extends React.Component<Props, State> {
let html: string let html: string
if (text) { if (text) {
html = Utils.htmlFromMarkdown(text) html = Utils.htmlFromMarkdown(text)
} else { } else {
html = Utils.htmlFromMarkdown(placeholderText || '') html = Utils.htmlFromMarkdown(placeholderText || '')
} }
const previewElement = const previewElement =
(<div (<div
ref={this.previewRef} ref={this.previewRef}
className={text ? 'octo-editor-preview' : 'octo-editor-preview octo-placeholder'} className={text ? 'octo-editor-preview' : 'octo-editor-preview octo-placeholder'}
style={{display: isEditing ? 'none' : null}} style={{display: isEditing ? 'none' : null}}
dangerouslySetInnerHTML={{__html: html}} dangerouslySetInnerHTML={{__html: html}}
onClick={() => { onClick={() => {
if (!isEditing) { if (!isEditing) {
this.showEditor() this.showEditor()
} }
}} }}
/>); />);
const editorElement = const editorElement =
@ -96,75 +96,75 @@ class MarkdownEditor extends React.Component<Props, State> {
// Use visibility instead of display here so the editor is pre-rendered, avoiding a flash on showEditor // Use visibility instead of display here so the editor is pre-rendered, avoiding a flash on showEditor
style={isEditing ? {} : {visibility: 'hidden', position: 'absolute', top: 0, left: 0}} style={isEditing ? {} : {visibility: 'hidden', position: 'absolute', top: 0, left: 0}}
onKeyDown={(e) => { onKeyDown={(e) => {
// HACKHACK: Need to handle here instad of in CodeMirror because that breaks auto-lists // HACKHACK: Need to handle here instad of in CodeMirror because that breaks auto-lists
if (e.keyCode === 27 && !e.shiftKey && !(e.ctrlKey || e.metaKey) && !e.altKey) { // Esc if (e.keyCode === 27 && !e.shiftKey && !(e.ctrlKey || e.metaKey) && !e.altKey) { // Esc
this.editorInstance?.codemirror?.getInputField()?.blur() this.editorInstance?.codemirror?.getInputField()?.blur()
} }
}} }}
> >
<SimpleMDE <SimpleMDE
id={uniqueId} id={uniqueId}
ref={this.elementRef} ref={this.elementRef}
getMdeInstance={(instance) => { getMdeInstance={(instance) => {
this.editorInstance = instance this.editorInstance = instance
// BUGBUG: This breaks auto-lists // BUGBUG: This breaks auto-lists
// instance.codemirror.setOption("extraKeys", { // instance.codemirror.setOption("extraKeys", {
// "Ctrl-Enter": (cm) => { // "Ctrl-Enter": (cm) => {
// cm.getInputField().blur() // cm.getInputField().blur()
// } // }
// }) // })
}} }}
value={text} value={text}
// onChange={() => { // onChange={() => {
// // We register a change onBlur, consider implementing "auto-save" later // // We register a change onBlur, consider implementing "auto-save" later
// }} // }}
events={{ events={{
blur: () => { blur: () => {
const newText = this.elementRef.current.state.value const newText = this.elementRef.current.state.value
const oldText = this.props.text || '' const oldText = this.props.text || ''
if (newText !== oldText && onChanged) { if (newText !== oldText && onChanged) {
const newHtml = newText ? Utils.htmlFromMarkdown(newText) : Utils.htmlFromMarkdown(placeholderText || '') const newHtml = newText ? Utils.htmlFromMarkdown(newText) : Utils.htmlFromMarkdown(placeholderText || '')
this.previewRef.current.innerHTML = newHtml this.previewRef.current.innerHTML = newHtml
onChanged(newText) onChanged(newText)
} }
this.text = newText this.text = newText
this.frameRef.current.classList.remove('active') this.frameRef.current.classList.remove('active')
if (onBlur) { if (onBlur) {
onBlur() onBlur()
} }
this.hideEditor() this.hideEditor()
}, },
focus: () => { focus: () => {
this.frameRef.current.classList.add('active') this.frameRef.current.classList.add('active')
this.elementRef.current.setState({value: this.text}) this.elementRef.current.setState({value: this.text})
if (onFocus) { if (onFocus) {
onFocus() onFocus()
} }
}, },
}} }}
options={{ options={{
autoDownloadFontAwesome: true, autoDownloadFontAwesome: true,
toolbar: false, toolbar: false,
status: false, status: false,
spellChecker: false, spellChecker: false,
minHeight: '10px', minHeight: '10px',
shortcuts: { shortcuts: {
toggleStrikethrough: 'Cmd-.', toggleStrikethrough: 'Cmd-.',
togglePreview: null, togglePreview: null,
drawImage: null, drawImage: null,
drawLink: null, drawLink: null,
toggleSideBySide: null, toggleSideBySide: null,
toggleFullScreen: null, toggleFullScreen: null,
}, },
}} }}
/> />
</div>) </div>)

View File

@ -13,11 +13,11 @@ export default class RootPortal extends React.PureComponent<Props> {
el: HTMLDivElement el: HTMLDivElement
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
} }
constructor(props: Props) { constructor(props: Props) {
super(props) super(props)
this.el = document.createElement('div') this.el = document.createElement('div')
} }
@ -25,20 +25,20 @@ export default class RootPortal extends React.PureComponent<Props> {
const rootPortal = document.getElementById('root-portal') const rootPortal = document.getElementById('root-portal')
if (rootPortal) { if (rootPortal) {
rootPortal.appendChild(this.el) rootPortal.appendChild(this.el)
} }
} }
componentWillUnmount() { componentWillUnmount() {
const rootPortal = document.getElementById('root-portal') const rootPortal = document.getElementById('root-portal')
if (rootPortal) { if (rootPortal) {
rootPortal.removeChild(this.el) rootPortal.removeChild(this.el)
} }
} }
render() { render() {
return ReactDOM.createPortal( return ReactDOM.createPortal(
this.props.children, this.props.children,
this.el, this.el,
) )
} }
} }

View File

@ -1,15 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react' import React from 'react'
import { Archiver } from '../archiver'
import { Board, MutableBoard } from '../blocks/board' import {Archiver} from '../archiver'
import { BoardTree } from '../viewModel/boardTree' import {Board, MutableBoard} from '../blocks/board'
import {BoardTree} from '../viewModel/boardTree'
import mutator from '../mutator' import mutator from '../mutator'
import Menu from '../widgets/menu' import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper' import MenuWrapper from '../widgets/menuWrapper'
import { WorkspaceTree } from '../viewModel/workspaceTree' import {WorkspaceTree} from '../viewModel/workspaceTree'
import { BoardView } from '../blocks/boardView' import {BoardView} from '../blocks/boardView'
type Props = { type Props = {
showBoard: (id: string) => void showBoard: (id: string) => void
@ -32,11 +32,16 @@ class Sidebar extends React.Component<Props> {
{ {
boards.map((board) => { boards.map((board) => {
const displayTitle = board.title || '(Untitled Board)' const displayTitle = board.title || '(Untitled Board)'
const boardViews = views.filter(view => view.parentId === board.id) const boardViews = views.filter((view) => view.parentId === board.id)
return ( return (
<div key={board.id}> <div key={board.id}>
<div className='octo-sidebar-item octo-hover-container'> <div className='octo-sidebar-item octo-hover-container'>
<div className='octo-sidebar-title' onClick={() => { this.boardClicked(board) }}> <div
className='octo-sidebar-title'
onClick={() => {
this.boardClicked(board)
}}
>
{board.icon ? `${board.icon} ${displayTitle}` : displayTitle} {board.icon ? `${board.icon} ${displayTitle}` : displayTitle}
</div> </div>
<div className='octo-spacer'/> <div className='octo-spacer'/>
@ -63,12 +68,20 @@ class Sidebar extends React.Component<Props> {
</Menu> </Menu>
</MenuWrapper> </MenuWrapper>
</div> </div>
{boardViews.map(view => { {boardViews.map((view) => {
return <div key={view.id} className='octo-sidebar-item subitem octo-hover-container'> return (<div
<div className='octo-sidebar-title' onClick={() => { this.viewClicked(board, view) }}> key={view.id}
className='octo-sidebar-item subitem octo-hover-container'
>
<div
className='octo-sidebar-title'
onClick={() => {
this.viewClicked(board, view)
}}
>
{view.title || '(Untitled View)'} {view.title || '(Untitled View)'}
</div> </div>
</div> </div>)
})} })}
</div> </div>
) )
@ -136,4 +149,4 @@ class Sidebar extends React.Component<Props> {
} }
} }
export { Sidebar } export {Sidebar}

View File

@ -24,23 +24,23 @@ class Switch extends React.Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props) super(props)
this.state = {isOn: props.isOn} this.state = {isOn: props.isOn}
} }
focus() { focus() {
this.elementRef.current.focus() this.elementRef.current.focus()
// Put cursor at end // Put cursor at end
document.execCommand('selectAll', false, null) document.execCommand('selectAll', false, null)
document.getSelection().collapseToEnd() document.getSelection().collapseToEnd()
} }
render() { render() {
const {style} = this.props const {style} = this.props
const {isOn} = this.state const {isOn} = this.state
const className = isOn ? 'octo-switch on' : 'octo-switch' const className = isOn ? 'octo-switch on' : 'octo-switch'
const element = const element =
(<div (<div
ref={this.elementRef} ref={this.elementRef}
className={className} className={className}
@ -59,12 +59,12 @@ class Switch extends React.Component<Props, State> {
} }
private async onClicked() { private async onClicked() {
const newIsOn = !this.state.isOn const newIsOn = !this.state.isOn
this.setState({isOn: newIsOn}) this.setState({isOn: newIsOn})
const {onChanged} = this.props const {onChanged} = this.props
onChanged(newIsOn) onChanged(newIsOn)
} }
} }

View File

@ -48,26 +48,26 @@ class TableComponent extends React.Component<Props, State> {
} }
componentDidUpdate(prevPros: Props, prevState: State) { componentDidUpdate(prevPros: Props, prevState: State) {
if (this.state.isSearching && !prevState.isSearching) { if (this.state.isSearching && !prevState.isSearching) {
this.searchFieldRef.current.focus() this.searchFieldRef.current.focus()
} }
} }
render() { render() {
const {boardTree, showView} = this.props const {boardTree, showView} = this.props
if (!boardTree || !boardTree.board) { if (!boardTree || !boardTree.board) {
return ( return (
<div>Loading...</div> <div>Loading...</div>
) )
} }
const {board, cards, activeView} = boardTree const {board, cards, activeView} = boardTree
const hasFilter = activeView.filter && activeView.filter.filters?.length > 0 const hasFilter = activeView.filter && activeView.filter.filters?.length > 0
const hasSort = activeView.sortOptions.length > 0 const hasSort = activeView.sortOptions.length > 0
this.cardIdToRowMap.clear() this.cardIdToRowMap.clear()
return ( return (
<div className='octo-app'> <div className='octo-app'>
@ -92,7 +92,7 @@ class TableComponent extends React.Component<Props, State> {
<Button <Button
style={{display: (!board.icon && this.state.isHoverOnCover) ? null : 'none'}} style={{display: (!board.icon && this.state.isHoverOnCover) ? null : 'none'}}
onClick={() => { onClick={() => {
const newIcon = BlockIcons.shared.randomIcon() const newIcon = BlockIcons.shared.randomIcon()
mutator.changeIcon(board, newIcon) mutator.changeIcon(board, newIcon)
}} }}
>Add Icon</Button> >Add Icon</Button>
@ -268,19 +268,19 @@ class TableComponent extends React.Component<Props, State> {
const openButonRef = React.createRef<HTMLDivElement>() const openButonRef = React.createRef<HTMLDivElement>()
const tableRowRef = React.createRef<TableRow>() const tableRowRef = React.createRef<TableRow>()
let focusOnMount = false let focusOnMount = false
if (this.cardIdToFocusOnRender && this.cardIdToFocusOnRender === card.id) { if (this.cardIdToFocusOnRender && this.cardIdToFocusOnRender === card.id) {
this.cardIdToFocusOnRender = undefined this.cardIdToFocusOnRender = undefined
focusOnMount = true focusOnMount = true
} }
const tableRow = (<TableRow const tableRow = (<TableRow
key={card.id} key={card.id}
ref={tableRowRef} ref={tableRowRef}
boardTree={boardTree} boardTree={boardTree}
card={card} card={card}
focusOnMount={focusOnMount} focusOnMount={focusOnMount}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.keyCode === 13) { if (e.keyCode === 13) {
// Enter: Insert new card if on last row // Enter: Insert new card if on last row
if (cards.length > 0 && cards[cards.length - 1] === card) { if (cards.length > 0 && cards[cards.length - 1] === card) {
@ -288,7 +288,7 @@ class TableComponent extends React.Component<Props, State> {
} }
} }
}} }}
/>) />)
this.cardIdToRowMap.set(card.id, tableRowRef) this.cardIdToRowMap.set(card.id, tableRowRef)
@ -311,7 +311,7 @@ class TableComponent extends React.Component<Props, State> {
</div> </div>
</div > </div >
</div > </div >
) )
} }
private async propertiesClicked(e: React.MouseEvent) { private async propertiesClicked(e: React.MouseEvent) {
@ -319,25 +319,25 @@ class TableComponent extends React.Component<Props, State> {
const {activeView} = boardTree const {activeView} = boardTree
const selectProperties = boardTree.board.cardProperties const selectProperties = boardTree.board.cardProperties
OldMenu.shared.options = selectProperties.map((o) => { OldMenu.shared.options = selectProperties.map((o) => {
const isVisible = activeView.visiblePropertyIds.includes(o.id) const isVisible = activeView.visiblePropertyIds.includes(o.id)
return {id: o.id, name: o.name, type: 'switch', isOn: isVisible} return {id: o.id, name: o.name, type: 'switch', isOn: isVisible}
}) })
OldMenu.shared.onMenuToggled = async (id: string, isOn: boolean) => { OldMenu.shared.onMenuToggled = async (id: string, isOn: boolean) => {
const property = selectProperties.find((o) => o.id === id) const property = selectProperties.find((o) => o.id === id)
Utils.assertValue(property) Utils.assertValue(property)
Utils.log(`Toggle property ${property.name} ${isOn}`) Utils.log(`Toggle property ${property.name} ${isOn}`)
let newVisiblePropertyIds = [] let newVisiblePropertyIds = []
if (activeView.visiblePropertyIds.includes(id)) { if (activeView.visiblePropertyIds.includes(id)) {
newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o) => o !== id) newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o) => o !== id)
} else { } else {
newVisiblePropertyIds = [...activeView.visiblePropertyIds, id] newVisiblePropertyIds = [...activeView.visiblePropertyIds, id]
} }
await mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds) await mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
} }
OldMenu.shared.showAtElement(e.target as HTMLElement) OldMenu.shared.showAtElement(e.target as HTMLElement)
} }
private filterClicked(e: React.MouseEvent) { private filterClicked(e: React.MouseEvent) {
@ -347,21 +347,21 @@ class TableComponent extends React.Component<Props, State> {
private async optionsClicked(e: React.MouseEvent) { private async optionsClicked(e: React.MouseEvent) {
const {boardTree} = this.props const {boardTree} = this.props
OldMenu.shared.options = [ OldMenu.shared.options = [
{id: 'exportCsv', name: 'Export to CSV'}, {id: 'exportCsv', name: 'Export to CSV'},
{id: 'exportBoardArchive', name: 'Export board archive'}, {id: 'exportBoardArchive', name: 'Export board archive'},
] ]
OldMenu.shared.onMenuClicked = async (id: string) => { OldMenu.shared.onMenuClicked = async (id: string) => {
switch (id) { switch (id) {
case 'exportCsv': { case 'exportCsv': {
CsvExporter.exportTableCsv(boardTree) CsvExporter.exportTableCsv(boardTree)
break break
} }
case 'exportBoardArchive': { case 'exportBoardArchive': {
Archiver.exportBoardTree(boardTree) Archiver.exportBoardTree(boardTree)
break break
} }
} }
} }
OldMenu.shared.showAtElement(e.target as HTMLElement) OldMenu.shared.showAtElement(e.target as HTMLElement)
@ -369,61 +369,61 @@ class TableComponent extends React.Component<Props, State> {
private async headerClicked(e: React.MouseEvent<HTMLDivElement>, templateId: string) { private async headerClicked(e: React.MouseEvent<HTMLDivElement>, templateId: string) {
const {boardTree} = this.props const {boardTree} = this.props
const {board} = boardTree const {board} = boardTree
const {activeView} = boardTree const {activeView} = boardTree
const options = [ const options = [
{id: 'sortAscending', name: 'Sort ascending'}, {id: 'sortAscending', name: 'Sort ascending'},
{id: 'sortDescending', name: 'Sort descending'}, {id: 'sortDescending', name: 'Sort descending'},
{id: 'insertLeft', name: 'Insert left'}, {id: 'insertLeft', name: 'Insert left'},
{id: 'insertRight', name: 'Insert right'}, {id: 'insertRight', name: 'Insert right'},
] ]
if (templateId !== '__name') { if (templateId !== '__name') {
options.push({id: 'hide', name: 'Hide'}) options.push({id: 'hide', name: 'Hide'})
options.push({id: 'duplicate', name: 'Duplicate'}) options.push({id: 'duplicate', name: 'Duplicate'})
options.push({id: 'delete', name: 'Delete'}) options.push({id: 'delete', name: 'Delete'})
} }
OldMenu.shared.options = options OldMenu.shared.options = options
OldMenu.shared.onMenuClicked = async (optionId: string, type?: string) => { OldMenu.shared.onMenuClicked = async (optionId: string, type?: string) => {
switch (optionId) { switch (optionId) {
case 'sortAscending': { case 'sortAscending': {
const newSortOptions = [ const newSortOptions = [
{propertyId: templateId, reversed: false}, {propertyId: templateId, reversed: false},
] ]
await mutator.changeViewSortOptions(activeView, newSortOptions) await mutator.changeViewSortOptions(activeView, newSortOptions)
break
}
case 'sortDescending': {
const newSortOptions = [
{propertyId: templateId, reversed: true},
]
await mutator.changeViewSortOptions(activeView, newSortOptions)
break break
} }
case 'insertLeft': { case 'sortDescending': {
const newSortOptions = [
{propertyId: templateId, reversed: true},
]
await mutator.changeViewSortOptions(activeView, newSortOptions)
break
}
case 'insertLeft': {
if (templateId !== '__name') { if (templateId !== '__name') {
const index = board.cardProperties.findIndex((o) => o.id === templateId) const index = board.cardProperties.findIndex((o) => o.id === templateId)
await mutator.insertPropertyTemplate(boardTree, index) await mutator.insertPropertyTemplate(boardTree, index)
} else { } else {
// TODO: Handle name column // TODO: Handle name column
} }
break break
} }
case 'insertRight': { case 'insertRight': {
if (templateId !== '__name') { if (templateId !== '__name') {
const index = board.cardProperties.findIndex((o) => o.id === templateId) + 1 const index = board.cardProperties.findIndex((o) => o.id === templateId) + 1
await mutator.insertPropertyTemplate(boardTree, index) await mutator.insertPropertyTemplate(boardTree, index)
} else { } else {
// TODO: Handle name column // TODO: Handle name column
} }
break break
} }
case 'duplicate': { case 'duplicate': {
await mutator.duplicatePropertyTemplate(boardTree, templateId) await mutator.duplicatePropertyTemplate(boardTree, templateId)
break break
} }
case 'hide': { case 'hide': {
const newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o) => o !== templateId) const newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o) => o !== templateId)
await mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds) await mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
@ -431,64 +431,64 @@ class TableComponent extends React.Component<Props, State> {
} }
case 'delete': { case 'delete': {
await mutator.deleteProperty(boardTree, templateId) await mutator.deleteProperty(boardTree, templateId)
break break
} }
default: { default: {
Utils.assertFailure(`Unexpected menu option: ${optionId}`) Utils.assertFailure(`Unexpected menu option: ${optionId}`)
break break
} }
} }
} }
OldMenu.shared.showAtElement(e.target as HTMLElement) OldMenu.shared.showAtElement(e.target as HTMLElement)
} }
focusOnCardTitle(cardId: string) { focusOnCardTitle(cardId: string) {
const tableRowRef = this.cardIdToRowMap.get(cardId) const tableRowRef = this.cardIdToRowMap.get(cardId)
Utils.log(`focusOnCardTitle, ${tableRowRef?.current ?? 'undefined'}`) Utils.log(`focusOnCardTitle, ${tableRowRef?.current ?? 'undefined'}`)
tableRowRef?.current.focusOnTitle() tableRowRef?.current.focusOnTitle()
} }
async addCard(show = false) { async addCard(show = false) {
const {boardTree} = this.props const {boardTree} = this.props
const card = new MutableCard() const card = new MutableCard()
card.parentId = boardTree.board.id card.parentId = boardTree.board.id
card.icon = BlockIcons.shared.randomIcon() card.icon = BlockIcons.shared.randomIcon()
await mutator.insertBlock( await mutator.insertBlock(
card, card,
'add card', 'add card',
async () => { async () => {
if (show) { if (show) {
this.setState({shownCard: card}) this.setState({shownCard: card})
} else { } else {
// Focus on this card's title inline on next render // Focus on this card's title inline on next render
this.cardIdToFocusOnRender = card.id this.cardIdToFocusOnRender = card.id
} }
}, },
) )
} }
private async onDropToColumn(template: IPropertyTemplate) { private async onDropToColumn(template: IPropertyTemplate) {
const {draggedHeaderTemplate} = this const {draggedHeaderTemplate} = this
if (!draggedHeaderTemplate) { if (!draggedHeaderTemplate) {
return return
} }
const {boardTree} = this.props const {boardTree} = this.props
const {board} = boardTree const {board} = boardTree
Utils.assertValue(mutator) Utils.assertValue(mutator)
Utils.assertValue(boardTree) Utils.assertValue(boardTree)
Utils.log(`ondrop. Source column: ${draggedHeaderTemplate.name}, dest column: ${template.name}`) Utils.log(`ondrop. Source column: ${draggedHeaderTemplate.name}, dest column: ${template.name}`)
// Move template to new index // Move template to new index
const destIndex = template ? board.cardProperties.indexOf(template) : 0 const destIndex = template ? board.cardProperties.indexOf(template) : 0
await mutator.changePropertyTemplateOrder(board, draggedHeaderTemplate, destIndex) await mutator.changePropertyTemplateOrder(board, draggedHeaderTemplate, destIndex)
} }
onSearchKeyDown(e: React.KeyboardEvent) { onSearchKeyDown(e: React.KeyboardEvent) {
if (e.keyCode === 27) { // ESC: Clear search if (e.keyCode === 27) { // ESC: Clear search
this.searchFieldRef.current.text = '' this.searchFieldRef.current.text = ''
this.setState({...this.state, isSearching: false}) this.setState({...this.state, isSearching: false})
this.props.setSearchText(undefined) this.props.setSearchText(undefined)
@ -497,7 +497,7 @@ class TableComponent extends React.Component<Props, State> {
} }
searchChanged(text?: string) { searchChanged(text?: string) {
this.props.setSearchText(text) this.props.setSearchText(text)
} }
} }

View File

@ -30,12 +30,12 @@ class TableRow extends React.Component<Props, State> {
componentDidMount() { componentDidMount() {
if (this.props.focusOnMount) { if (this.props.focusOnMount) {
this.titleRef.current.focus() this.titleRef.current.focus()
} }
} }
render() { render() {
const {boardTree, card, onKeyDown} = this.props const {boardTree, card, onKeyDown} = this.props
const {board, activeView} = boardTree const {board, activeView} = boardTree
const openButonRef = React.createRef<HTMLDivElement>() const openButonRef = React.createRef<HTMLDivElement>()
@ -104,7 +104,7 @@ class TableRow extends React.Component<Props, State> {
})} })}
</div>) </div>)
return element return element
} }
focusOnTitle() { focusOnTitle() {

View File

@ -17,38 +17,38 @@ type Props = {
export default class ViewMenu extends React.Component<Props> { export default class ViewMenu extends React.Component<Props> {
handleDeleteView = async (id: string) => { handleDeleteView = async (id: string) => {
const {board, boardTree, showView} = this.props const {board, boardTree, showView} = this.props
Utils.log('deleteView') Utils.log('deleteView')
const view = boardTree.activeView const view = boardTree.activeView
const nextView = boardTree.views.find((o) => o !== view) const nextView = boardTree.views.find((o) => o !== view)
await mutator.deleteBlock(view, 'delete view') await mutator.deleteBlock(view, 'delete view')
showView(nextView.id) showView(nextView.id)
} }
handleViewClick = (id: string) => { handleViewClick = (id: string) => {
const {boardTree, showView} = this.props const {boardTree, showView} = this.props
Utils.log('view ' + id) Utils.log('view ' + id)
const view = boardTree.views.find((o) => o.id === id) const view = boardTree.views.find((o) => o.id === id)
showView(view.id) showView(view.id)
} }
handleAddViewBoard = async (id: string) => { handleAddViewBoard = async (id: string) => {
const {board, boardTree, showView} = this.props const {board, boardTree, showView} = this.props
Utils.log('addview-board') Utils.log('addview-board')
const view = new MutableBoardView() const view = new MutableBoardView()
view.title = 'Board View' view.title = 'Board View'
view.viewType = 'board' view.viewType = 'board'
view.parentId = board.id view.parentId = board.id
const oldViewId = boardTree.activeView.id const oldViewId = boardTree.activeView.id
await mutator.insertBlock( await mutator.insertBlock(
view, view,
'add view', 'add view',
async () => { async () => {
showView(view.id) showView(view.id)
}, },
async () => { async () => {
showView(oldViewId) showView(oldViewId)
}) })
} }
@ -56,19 +56,19 @@ export default class ViewMenu extends React.Component<Props> {
handleAddViewTable = async (id: string) => { handleAddViewTable = async (id: string) => {
const {board, boardTree, showView} = this.props const {board, boardTree, showView} = this.props
Utils.log('addview-table') Utils.log('addview-table')
const view = new MutableBoardView() const view = new MutableBoardView()
view.title = 'Table View' view.title = 'Table View'
view.viewType = 'table' view.viewType = 'table'
view.parentId = board.id view.parentId = board.id
view.visiblePropertyIds = board.cardProperties.map((o) => o.id) view.visiblePropertyIds = board.cardProperties.map((o) => o.id)
const oldViewId = boardTree.activeView.id const oldViewId = boardTree.activeView.id
await mutator.insertBlock( await mutator.insertBlock(
view, view,
'add view', 'add view',
async () => { async () => {
showView(view.id) showView(view.id)
}, },
async () => { async () => {
@ -77,7 +77,7 @@ export default class ViewMenu extends React.Component<Props> {
} }
render() { render() {
const {boardTree} = this.props const {boardTree} = this.props
return ( return (
<Menu> <Menu>
{boardTree.views.map((view) => (<Menu.Text {boardTree.views.map((view) => (<Menu.Text

View File

@ -11,13 +11,13 @@ class FilterClause {
static filterConditionDisplayString(filterCondition: FilterCondition) { static filterConditionDisplayString(filterCondition: FilterCondition) {
switch (filterCondition) { switch (filterCondition) {
case 'includes': return 'includes' case 'includes': return 'includes'
case 'notIncludes': return "doesn't include" case 'notIncludes': return "doesn't include"
case 'isEmpty': return 'is empty' case 'isEmpty': return 'is empty'
case 'isNotEmpty': return 'is not empty' case 'isNotEmpty': return 'is not empty'
default: { default: {
Utils.assertFailure() Utils.assertFailure()
return '(unknown)' return '(unknown)'
} }
} }
} }
@ -29,10 +29,10 @@ class FilterClause {
} }
isEqual(o: FilterClause) { isEqual(o: FilterClause) {
return ( return (
this.propertyId === o.propertyId && this.propertyId === o.propertyId &&
this.condition === o.condition && this.condition === o.condition &&
Utils.arraysEqual(this.values, o.values) Utils.arraysEqual(this.values, o.values)
) )
} }
} }

View File

@ -10,12 +10,12 @@ class FilterGroup {
filters: (FilterClause | FilterGroup)[] = [] filters: (FilterClause | FilterGroup)[] = []
static isAnInstanceOf(object: any): object is FilterGroup { static isAnInstanceOf(object: any): object is FilterGroup {
return 'innerOperation' in object && 'filters' in object return 'innerOperation' in object && 'filters' in object
} }
constructor(o: any = {}) { constructor(o: any = {}) {
this.operation = o.operation || 'and' this.operation = o.operation || 'and'
this.filters = o.filters ? this.filters = o.filters ?
o.filters.map((p: any) => { o.filters.map((p: any) => {
if (FilterGroup.isAnInstanceOf(p)) { if (FilterGroup.isAnInstanceOf(p)) {
return new FilterGroup(p) return new FilterGroup(p)

View File

@ -37,13 +37,13 @@ class Menu {
const menuElement = menu.appendChild(Utils.htmlToElement('<div class="menu-options"></div>')) const menuElement = menu.appendChild(Utils.htmlToElement('<div class="menu-options"></div>'))
this.appendMenuOptions(menuElement) this.appendMenuOptions(menuElement)
return menu return menu
} }
appendMenuOptions(menuElement: HTMLElement) { appendMenuOptions(menuElement: HTMLElement) {
for (const option of this.options) { for (const option of this.options) {
if (option.type === 'separator') { if (option.type === 'separator') {
const optionElement = menuElement.appendChild(Utils.htmlToElement('<div class="menu-separator"></div>')) const optionElement = menuElement.appendChild(Utils.htmlToElement('<div class="menu-separator"></div>'))
} else { } else {
const optionElement = menuElement.appendChild(Utils.htmlToElement('<div class="menu-option"></div>')) const optionElement = menuElement.appendChild(Utils.htmlToElement('<div class="menu-option"></div>'))
optionElement.id = option.id optionElement.id = option.id
@ -53,62 +53,62 @@ class Menu {
optionElement.appendChild(Utils.htmlToElement('<div class="imageSubmenuTriangle" style="float: right;"></div>')) optionElement.appendChild(Utils.htmlToElement('<div class="imageSubmenuTriangle" style="float: right;"></div>'))
optionElement.onmouseenter = (e) => { optionElement.onmouseenter = (e) => {
// Calculate offset taking window scroll into account // Calculate offset taking window scroll into account
const bodyRect = document.body.getBoundingClientRect() const bodyRect = document.body.getBoundingClientRect()
const rect = optionElement.getBoundingClientRect() const rect = optionElement.getBoundingClientRect()
this.showSubMenu(rect.right - bodyRect.left, rect.top - bodyRect.top, option.id) this.showSubMenu(rect.right - bodyRect.left, rect.top - bodyRect.top, option.id)
} }
} else { } else {
if (option.icon) { if (option.icon) {
let iconName: string let iconName: string
switch (option.icon) { switch (option.icon) {
case 'checked': { iconName = 'imageMenuCheck'; break } case 'checked': { iconName = 'imageMenuCheck'; break }
case 'sortUp': { iconName = 'imageMenuSortUp'; break } case 'sortUp': { iconName = 'imageMenuSortUp'; break }
case 'sortDown': { iconName = 'imageMenuSortDown'; break } case 'sortDown': { iconName = 'imageMenuSortDown'; break }
default: { Utils.assertFailure(`Unsupported menu icon: ${option.icon}`) } default: { Utils.assertFailure(`Unsupported menu icon: ${option.icon}`) }
}
if (iconName) {
optionElement.appendChild(Utils.htmlToElement(`<div class="${iconName}" style="float: right;"></div>`))
} }
} if (iconName) {
optionElement.appendChild(Utils.htmlToElement(`<div class="${iconName}" style="float: right;"></div>`))
}
}
optionElement.onmouseenter = () => { optionElement.onmouseenter = () => {
this.hideSubMenu() this.hideSubMenu()
} }
optionElement.onclick = (e) => { optionElement.onclick = (e) => {
if (this.onMenuClicked) { if (this.onMenuClicked) {
this.onMenuClicked(option.id, option.type) this.onMenuClicked(option.id, option.type)
} }
this.hide() this.hide()
e.stopPropagation() e.stopPropagation()
return false return false
} }
} }
if (option.type === 'color') { if (option.type === 'color') {
const colorbox = optionElement.insertBefore(Utils.htmlToElement('<div class="menu-colorbox"></div>'), optionElement.firstChild) const colorbox = optionElement.insertBefore(Utils.htmlToElement('<div class="menu-colorbox"></div>'), optionElement.firstChild)
colorbox.classList.add(option.id) // id is the css class name for the color colorbox.classList.add(option.id) // id is the css class name for the color
} else if (option.type === 'switch') { } else if (option.type === 'switch') {
const className = option.isOn ? 'octo-switch on' : 'octo-switch' const className = option.isOn ? 'octo-switch on' : 'octo-switch'
const switchElement = optionElement.appendChild(Utils.htmlToElement(`<div class="${className}"></div>`)) const switchElement = optionElement.appendChild(Utils.htmlToElement(`<div class="${className}"></div>`))
switchElement.appendChild(Utils.htmlToElement('<div class="octo-switch-inner"></div>')) switchElement.appendChild(Utils.htmlToElement('<div class="octo-switch-inner"></div>'))
switchElement.onclick = (e) => { switchElement.onclick = (e) => {
const isOn = switchElement.classList.contains('on') const isOn = switchElement.classList.contains('on')
if (isOn) { if (isOn) {
switchElement.classList.remove('on') switchElement.classList.remove('on')
} else { } else {
switchElement.classList.add('on') switchElement.classList.add('on')
} }
if (this.onMenuToggled) { if (this.onMenuToggled) {
this.onMenuToggled(option.id, !isOn) this.onMenuToggled(option.id, !isOn)
} }
e.stopPropagation() e.stopPropagation()
return false return false
} }
optionElement.onclick = null optionElement.onclick = null
} }
} }
} }
} }
showAtElement(element: HTMLElement) { showAtElement(element: HTMLElement) {
@ -120,46 +120,46 @@ class Menu {
} }
showAt(pageX: number, pageY: number) { showAt(pageX: number, pageY: number) {
if (this.menu) { if (this.menu) {
this.hide() this.hide()
} }
this.menu = this.createMenuElement() this.menu = this.createMenuElement()
this.menu.style.left = `${pageX}px` this.menu.style.left = `${pageX}px`
this.menu.style.top = `${pageY}px` this.menu.style.top = `${pageY}px`
document.body.appendChild(this.menu) document.body.appendChild(this.menu)
this.onBodyClick = (e: MouseEvent) => { this.onBodyClick = (e: MouseEvent) => {
console.log('onBodyClick') console.log('onBodyClick')
this.hide() this.hide()
} }
this.onBodyKeyDown = (e: KeyboardEvent) => { this.onBodyKeyDown = (e: KeyboardEvent) => {
console.log(`onBodyKeyDown, target: ${e.target}`) console.log(`onBodyKeyDown, target: ${e.target}`)
// Ignore keydown events on other elements // Ignore keydown events on other elements
if (e.target !== document.body) { if (e.target !== document.body) {
return return
} }
if (e.keyCode === 27) { if (e.keyCode === 27) {
// ESC // ESC
this.hide() this.hide()
e.stopPropagation() e.stopPropagation()
} }
} }
setTimeout(() => { setTimeout(() => {
document.body.addEventListener('click', this.onBodyClick) document.body.addEventListener('click', this.onBodyClick)
document.body.addEventListener('keydown', this.onBodyKeyDown) document.body.addEventListener('keydown', this.onBodyKeyDown)
}, 20) }, 20)
} }
hide() { hide() {
if (!this.menu) { if (!this.menu) {
return return
} }
this.hideSubMenu() this.hideSubMenu()
document.body.removeChild(this.menu) document.body.removeChild(this.menu)
this.menu = undefined this.menu = undefined
@ -167,34 +167,34 @@ class Menu {
document.body.removeEventListener('click', this.onBodyClick) document.body.removeEventListener('click', this.onBodyClick)
this.onBodyClick = undefined this.onBodyClick = undefined
document.body.removeEventListener('keydown', this.onBodyKeyDown) document.body.removeEventListener('keydown', this.onBodyKeyDown)
this.onBodyKeyDown = undefined this.onBodyKeyDown = undefined
} }
hideSubMenu() { hideSubMenu() {
if (this.subMenu) { if (this.subMenu) {
this.subMenu.hide() this.subMenu.hide()
this.subMenu = undefined this.subMenu = undefined
} }
} }
private showSubMenu(pageX: number, pageY: number, id: string) { private showSubMenu(pageX: number, pageY: number, id: string) {
console.log(`showSubMenu: ${id}`) console.log(`showSubMenu: ${id}`)
const options: MenuOption[] = this.subMenuOptions.get(id) || [] const options: MenuOption[] = this.subMenuOptions.get(id) || []
if (this.subMenu) { if (this.subMenu) {
if (this.subMenu.options === options) { if (this.subMenu.options === options) {
// Already showing the sub menu // Already showing the sub menu
return return
} }
this.subMenu.hide() this.subMenu.hide()
} }
this.subMenu = new Menu() this.subMenu = new Menu()
this.subMenu.onMenuClicked = (optionId: string, type?: string) => { this.subMenu.onMenuClicked = (optionId: string, type?: string) => {
const subMenuId = `${id}-${optionId}` const subMenuId = `${id}-${optionId}`
if (this.onMenuClicked) { if (this.onMenuClicked) {
this.onMenuClicked(subMenuId, type) this.onMenuClicked(subMenuId, type)
} }

View File

@ -97,20 +97,20 @@ class Mutator {
} }
async changeIcon(block: Card | Board, icon: string, description = 'change icon') { async changeIcon(block: Card | Board, icon: string, description = 'change icon') {
var newBlock: IBlock let newBlock: IBlock
switch (block.type) { switch (block.type) {
case 'card': { case 'card': {
const card = new MutableCard(block) const card = new MutableCard(block)
card.icon = icon card.icon = icon
newBlock = card newBlock = card
break break
} }
case 'board': { case 'board': {
const board = new MutableBoard(block) const board = new MutableBoard(block)
board.icon = icon board.icon = icon
newBlock = board newBlock = board
break break
} }
} }
await this.updateBlock(newBlock, block, description) await this.updateBlock(newBlock, block, description)
@ -265,7 +265,7 @@ class Mutator {
Utils.assert(board.cardProperties.includes(template)) Utils.assert(board.cardProperties.includes(template))
const newBoard = new MutableBoard(board) const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find(o => o.id === template.id) const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
newTemplate.options.push(option) newTemplate.options.push(option)
await this.updateBlock(newBoard, board, description) await this.updateBlock(newBoard, board, description)
@ -275,8 +275,8 @@ class Mutator {
const {board} = boardTree const {board} = boardTree
const newBoard = new MutableBoard(board) const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find(o => o.id === template.id) const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
newTemplate.options = newTemplate.options.filter(o => o.value !== option.value) newTemplate.options = newTemplate.options.filter((o) => o.value !== option.value)
await this.updateBlock(newBoard, board, 'delete option') await this.updateBlock(newBoard, board, 'delete option')
} }
@ -286,7 +286,7 @@ class Mutator {
Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`) Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`)
const newBoard = new MutableBoard(board) const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find(o => o.id === template.id) const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
newTemplate.options.splice(destIndex, 0, newTemplate.options.splice(srcIndex, 1)[0]) newTemplate.options.splice(destIndex, 0, newTemplate.options.splice(srcIndex, 1)[0])
await this.updateBlock(newBoard, board, 'reorder options') await this.updateBlock(newBoard, board, 'reorder options')
@ -299,8 +299,8 @@ class Mutator {
const oldBlocks: IBlock[] = [board] const oldBlocks: IBlock[] = [board]
const newBoard = new MutableBoard(board) const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find(o => o.id === propertyTemplate.id) const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)
const newOption = newTemplate.options.find(o => o.value === oldValue) const newOption = newTemplate.options.find((o) => o.value === oldValue)
newOption.value = value newOption.value = value
const changedBlocks: IBlock[] = [newBoard] const changedBlocks: IBlock[] = [newBoard]
@ -323,8 +323,8 @@ class Mutator {
async changePropertyOptionColor(board: Board, template: IPropertyTemplate, option: IPropertyOption, color: string) { async changePropertyOptionColor(board: Board, template: IPropertyTemplate, option: IPropertyOption, color: string) {
const newBoard = new MutableBoard(board) const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find(o => o.id === template.id) const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)
const newOption = newTemplate.options.find(o => o.value === option.value) const newOption = newTemplate.options.find((o) => o.value === option.value)
newOption.color = color newOption.color = color
await this.updateBlock(newBoard, board, 'change option color') await this.updateBlock(newBoard, board, 'change option color')
} }
@ -337,7 +337,7 @@ class Mutator {
async changePropertyType(board: Board, propertyTemplate: IPropertyTemplate, type: PropertyType) { async changePropertyType(board: Board, propertyTemplate: IPropertyTemplate, type: PropertyType) {
const newBoard = new MutableBoard(board) const newBoard = new MutableBoard(board)
const newTemplate = newBoard.cardProperties.find(o => o.id === propertyTemplate.id) const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)
newTemplate.type = type newTemplate.type = type
await this.updateBlock(newBoard, board, 'change property type') await this.updateBlock(newBoard, board, 'change property type')
} }
@ -356,7 +356,7 @@ class Mutator {
await this.updateBlock(newView, view, 'filter') await this.updateBlock(newView, view, 'filter')
} }
async changeViewVisibleProperties(view: BoardView, visiblePropertyIds: string[], description: string = 'show / hide property') { async changeViewVisibleProperties(view: BoardView, visiblePropertyIds: string[], description = 'show / hide property') {
const newView = new MutableBoardView(view) const newView = new MutableBoardView(view)
newView.visiblePropertyIds = visiblePropertyIds newView.visiblePropertyIds = visiblePropertyIds
await this.updateBlock(newView, view, description) await this.updateBlock(newView, view, description)

View File

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import { IMutableBlock } from './blocks/block' import {IMutableBlock} from './blocks/block'
import {IBlock} from './blocks/block' import {IBlock} from './blocks/block'
import {Utils} from './utils' import {Utils} from './utils'
@ -11,48 +11,48 @@ class OctoClient {
serverUrl: string serverUrl: string
constructor(serverUrl?: string) { constructor(serverUrl?: string) {
this.serverUrl = serverUrl || window.location.origin this.serverUrl = serverUrl || window.location.origin
console.log(`OctoClient serverUrl: ${this.serverUrl}`) console.log(`OctoClient serverUrl: ${this.serverUrl}`)
} }
async getSubtree(rootId?: string): Promise<IBlock[]> { async getSubtree(rootId?: string): Promise<IBlock[]> {
const path = `/api/v1/blocks/${rootId}/subtree` const path = `/api/v1/blocks/${rootId}/subtree`
const response = await fetch(this.serverUrl + path) const response = await fetch(this.serverUrl + path)
const blocks = (await response.json() || []) as IMutableBlock[] const blocks = (await response.json() || []) as IMutableBlock[]
this.fixBlocks(blocks) this.fixBlocks(blocks)
return blocks return blocks
} }
async exportFullArchive(): Promise<IBlock[]> { async exportFullArchive(): Promise<IBlock[]> {
const path = '/api/v1/blocks/export' const path = '/api/v1/blocks/export'
const response = await fetch(this.serverUrl + path) const response = await fetch(this.serverUrl + path)
const blocks = (await response.json() || []) as IMutableBlock[] const blocks = (await response.json() || []) as IMutableBlock[]
this.fixBlocks(blocks) this.fixBlocks(blocks)
return blocks return blocks
} }
async importFullArchive(blocks: IBlock[]): Promise<Response> { async importFullArchive(blocks: IBlock[]): Promise<Response> {
Utils.log(`importFullArchive: ${blocks.length} blocks(s)`) Utils.log(`importFullArchive: ${blocks.length} blocks(s)`)
blocks.forEach((block) => { blocks.forEach((block) => {
Utils.log(`\t ${block.type}, ${block.id}`) Utils.log(`\t ${block.type}, ${block.id}`)
}) })
const body = JSON.stringify(blocks) const body = JSON.stringify(blocks)
return await fetch(this.serverUrl + '/api/v1/blocks/import', { return await fetch(this.serverUrl + '/api/v1/blocks/import', {
method: 'POST', method: 'POST',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body, body,
}) })
} }
async getBlocksWithParent(parentId: string, type?: string): Promise<IBlock[]> { async getBlocksWithParent(parentId: string, type?: string): Promise<IBlock[]> {
let path: string let path: string
if (type) { if (type) {
path = `/api/v1/blocks?parent_id=${encodeURIComponent(parentId)}&type=${encodeURIComponent(type)}` path = `/api/v1/blocks?parent_id=${encodeURIComponent(parentId)}&type=${encodeURIComponent(type)}`
} else { } else {
path = `/api/v1/blocks?parent_id=${encodeURIComponent(parentId)}` path = `/api/v1/blocks?parent_id=${encodeURIComponent(parentId)}`
} }
return this.getBlocksWithPath(path) return this.getBlocksWithPath(path)
} }
@ -63,20 +63,20 @@ class OctoClient {
} }
private async getBlocksWithPath(path: string): Promise<IBlock[]> { private async getBlocksWithPath(path: string): Promise<IBlock[]> {
const response = await fetch(this.serverUrl + path) const response = await fetch(this.serverUrl + path)
const blocks = (await response.json() || []) as IMutableBlock[] const blocks = (await response.json() || []) as IMutableBlock[]
this.fixBlocks(blocks) this.fixBlocks(blocks)
return blocks return blocks
} }
fixBlocks(blocks: IMutableBlock[]): void { fixBlocks(blocks: IMutableBlock[]): void {
if (!blocks) { if (!blocks) {
return return
} }
// TODO // TODO
for (const block of blocks) { for (const block of blocks) {
if (!block.fields) { if (!block.fields) {
block.fields = {} block.fields = {}
} }
const o = block as any const o = block as any
@ -105,12 +105,12 @@ class OctoClient {
blocks.forEach((block) => { blocks.forEach((block) => {
block.updateAt = now block.updateAt = now
}) })
return await this.insertBlocks(blocks) return await this.insertBlocks(blocks)
} }
async deleteBlock(blockId: string): Promise<Response> { async deleteBlock(blockId: string): Promise<Response> {
console.log(`deleteBlock: ${blockId}`) console.log(`deleteBlock: ${blockId}`)
return await fetch(this.serverUrl + `/api/v1/blocks/${encodeURIComponent(blockId)}`, { return await fetch(this.serverUrl + `/api/v1/blocks/${encodeURIComponent(blockId)}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -120,19 +120,19 @@ class OctoClient {
} }
async insertBlock(block: IBlock): Promise<Response> { async insertBlock(block: IBlock): Promise<Response> {
return this.insertBlocks([block]) return this.insertBlocks([block])
} }
async insertBlocks(blocks: IBlock[]): Promise<Response> { async insertBlocks(blocks: IBlock[]): Promise<Response> {
Utils.log(`insertBlocks: ${blocks.length} blocks(s)`) Utils.log(`insertBlocks: ${blocks.length} blocks(s)`)
blocks.forEach((block) => { blocks.forEach((block) => {
Utils.log(`\t ${block.type}, ${block.id}`) Utils.log(`\t ${block.type}, ${block.id}`)
}) })
const body = JSON.stringify(blocks) const body = JSON.stringify(blocks)
return await fetch(this.serverUrl + '/api/v1/blocks', { return await fetch(this.serverUrl + '/api/v1/blocks', {
method: 'POST', method: 'POST',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body, body,
@ -142,33 +142,33 @@ class OctoClient {
// Returns URL of uploaded file, or undefined on failure // Returns URL of uploaded file, or undefined on failure
async uploadFile(file: File): Promise<string | undefined> { async uploadFile(file: File): Promise<string | undefined> {
// IMPORTANT: We need to post the image as a form. The browser will convert this to a application/x-www-form-urlencoded POST // IMPORTANT: We need to post the image as a form. The browser will convert this to a application/x-www-form-urlencoded POST
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
try { try {
const response = await fetch(this.serverUrl + '/api/v1/files', { const response = await fetch(this.serverUrl + '/api/v1/files', {
method: 'POST', method: 'POST',
// TIPTIP: Leave out Content-Type here, it will be automatically set by the browser // TIPTIP: Leave out Content-Type here, it will be automatically set by the browser
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
}, },
body: formData, body: formData,
}) })
if (response.status === 200) { if (response.status === 200) {
try { try {
const text = await response.text() const text = await response.text()
Utils.log(`uploadFile response: ${text}`) Utils.log(`uploadFile response: ${text}`)
const json = JSON.parse(text) const json = JSON.parse(text)
// const json = await response.json() // const json = await response.json()
return json.url return json.url
} catch (e) { } catch (e) {
Utils.logError(`uploadFile json ERROR: ${e}`) Utils.logError(`uploadFile json ERROR: ${e}`)
} }
} }
} catch (e) { } catch (e) {
Utils.logError(`uploadFile ERROR: ${e}`) Utils.logError(`uploadFile ERROR: ${e}`)
} }
return undefined return undefined

View File

@ -36,64 +36,64 @@ class OctoListener {
} }
open(blockIds: string[], onChange: (blockId: string) => void) { open(blockIds: string[], onChange: (blockId: string) => void) {
let timeoutId: NodeJS.Timeout let timeoutId: NodeJS.Timeout
if (this.ws) { if (this.ws) {
this.close() this.close()
} }
const url = new URL(this.serverUrl) const url = new URL(this.serverUrl)
const wsServerUrl = `ws://${url.host}${url.pathname}ws/onchange` const wsServerUrl = `ws://${url.host}${url.pathname}ws/onchange`
Utils.log(`OctoListener open: ${wsServerUrl}`) Utils.log(`OctoListener open: ${wsServerUrl}`)
const ws = new WebSocket(wsServerUrl) const ws = new WebSocket(wsServerUrl)
this.ws = ws this.ws = ws
ws.onopen = () => { ws.onopen = () => {
Utils.log(`OctoListener webSocket opened.`) Utils.log('OctoListener webSocket opened.')
this.addBlocks(blockIds) this.addBlocks(blockIds)
this.isInitialized = true this.isInitialized = true
} }
ws.onerror = (e) => { ws.onerror = (e) => {
Utils.logError(`OctoListener websocket onerror. data: ${e}`) Utils.logError(`OctoListener websocket onerror. data: ${e}`)
} }
ws.onclose = (e) => { ws.onclose = (e) => {
Utils.log(`OctoListener websocket onclose, code: ${e.code}, reason: ${e.reason}`) Utils.log(`OctoListener websocket onclose, code: ${e.code}, reason: ${e.reason}`)
if (ws === this.ws) { if (ws === this.ws) {
// Unexpected close, re-open // Unexpected close, re-open
const reopenBlockIds = this.isInitialized ? this.blockIds.slice() : blockIds.slice() const reopenBlockIds = this.isInitialized ? this.blockIds.slice() : blockIds.slice()
Utils.logError(`Unexpected close, re-opening with ${reopenBlockIds.length} blocks...`) Utils.logError(`Unexpected close, re-opening with ${reopenBlockIds.length} blocks...`)
setTimeout(() => { setTimeout(() => {
this.open(reopenBlockIds, onChange) this.open(reopenBlockIds, onChange)
}, this.reopenDelay) }, this.reopenDelay)
} }
} }
ws.onmessage = (e) => { ws.onmessage = (e) => {
Utils.log(`OctoListener websocket onmessage. data: ${e.data}`) Utils.log(`OctoListener websocket onmessage. data: ${e.data}`)
if (ws !== this.ws) { if (ws !== this.ws) {
Utils.log(`Ignoring closed ws`) Utils.log('Ignoring closed ws')
return return
} }
try { try {
const message = JSON.parse(e.data) as WSMessage const message = JSON.parse(e.data) as WSMessage
switch (message.action) { switch (message.action) {
case 'UPDATE_BLOCK': case 'UPDATE_BLOCK':
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
} }
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
timeoutId = undefined timeoutId = undefined
onChange(message.blockId) onChange(message.blockId)
}, this.notificationDelay) }, this.notificationDelay)
break break
default: default:
Utils.logError(`Unexpected action: ${message.action}`) Utils.logError(`Unexpected action: ${message.action}`)
} }
} catch (e) { } catch (e) {
Utils.log('message is not an object') Utils.log('message is not an object')
} }
} }
} }
@ -115,13 +115,13 @@ class OctoListener {
addBlocks(blockIds: string[]): void { addBlocks(blockIds: string[]): void {
if (!this.isOpen) { if (!this.isOpen) {
Utils.assertFailure(`OctoListener.addBlocks: ws is not open`) Utils.assertFailure('OctoListener.addBlocks: ws is not open')
return return
} }
const command: WSCommand = { const command: WSCommand = {
action: 'ADD', action: 'ADD',
blockIds blockIds,
} }
this.ws.send(JSON.stringify(command)) this.ws.send(JSON.stringify(command))
@ -130,20 +130,20 @@ class OctoListener {
removeBlocks(blockIds: string[]): void { removeBlocks(blockIds: string[]): void {
if (!this.isOpen) { if (!this.isOpen) {
Utils.assertFailure(`OctoListener.removeBlocks: ws is not open`) Utils.assertFailure('OctoListener.removeBlocks: ws is not open')
return return
} }
const command: WSCommand = { const command: WSCommand = {
action: 'REMOVE', action: 'REMOVE',
blockIds blockIds,
} }
this.ws.send(JSON.stringify(command)) this.ws.send(JSON.stringify(command))
// Remove registered blockIds, maintinging multiple copies (simple ref-counting) // Remove registered blockIds, maintinging multiple copies (simple ref-counting)
for (let i=0; i<this.blockIds.length; i++) { for (let i = 0; i < this.blockIds.length; i++) {
for (let j=0; j<blockIds.length; j++) { for (let j = 0; j < blockIds.length; j++) {
if (this.blockIds[i] === blockIds[j]) { if (this.blockIds[i] === blockIds[j]) {
this.blockIds.splice(i, 1) this.blockIds.splice(i, 1)
blockIds.splice(j, 1) blockIds.splice(j, 1)

View File

@ -34,33 +34,33 @@ export default class BoardPage extends React.Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props) super(props)
const queryString = new URLSearchParams(window.location.search) const queryString = new URLSearchParams(window.location.search)
const boardId = queryString.get('id') const boardId = queryString.get('id')
const viewId = queryString.get('v') const viewId = queryString.get('v')
this.state = { this.state = {
boardId, boardId,
viewId, viewId,
workspaceTree: new MutableWorkspaceTree(), workspaceTree: new MutableWorkspaceTree(),
} }
Utils.log(`BoardPage. boardId: ${boardId}`) Utils.log(`BoardPage. boardId: ${boardId}`)
} }
componentDidUpdate(prevProps: Props, prevState: State) { componentDidUpdate(prevProps: Props, prevState: State) {
Utils.log('componentDidUpdate') Utils.log('componentDidUpdate')
const board = this.state.boardTree?.board const board = this.state.boardTree?.board
const prevBoard = prevState.boardTree?.board const prevBoard = prevState.boardTree?.board
const activeView = this.state.boardTree?.activeView const activeView = this.state.boardTree?.activeView
const prevActiveView = prevState.boardTree?.activeView const prevActiveView = prevState.boardTree?.activeView
if (board?.icon !== prevBoard?.icon) { if (board?.icon !== prevBoard?.icon) {
Utils.setFavicon(board?.icon) Utils.setFavicon(board?.icon)
} }
if (board?.title !== prevBoard?.title || activeView?.title !== prevActiveView?.title) { if (board?.title !== prevBoard?.title || activeView?.title !== prevActiveView?.title) {
document.title = `OCTO - ${board?.title} | ${activeView?.title}` document.title = `OCTO - ${board?.title} | ${activeView?.title}`
} }
} }
undoRedoHandler = async (e: KeyboardEvent) => { undoRedoHandler = async (e: KeyboardEvent) => {
@ -68,53 +68,53 @@ export default class BoardPage extends React.Component<Props, State> {
return return
} }
if (e.keyCode === 90 && !e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Cmd+Z if (e.keyCode === 90 && !e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Cmd+Z
Utils.log('Undo') Utils.log('Undo')
const description = mutator.undoDescription() const description = mutator.undoDescription()
await mutator.undo() await mutator.undo()
if (description) { if (description) {
FlashMessage.show(`Undo ${description}`) FlashMessage.show(`Undo ${description}`)
} else { } else {
FlashMessage.show('Undo') FlashMessage.show('Undo')
} }
} else if (e.keyCode === 90 && e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Shift+Cmd+Z } else if (e.keyCode === 90 && e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Shift+Cmd+Z
Utils.log('Redo') Utils.log('Redo')
const description = mutator.redoDescription() const description = mutator.redoDescription()
await mutator.redo() await mutator.redo()
if (description) { if (description) {
FlashMessage.show(`Redo ${description}`) FlashMessage.show(`Redo ${description}`)
} else { } else {
FlashMessage.show('Redo') FlashMessage.show('Redo')
} }
} }
} }
componentDidMount() { componentDidMount() {
document.addEventListener('keydown', this.undoRedoHandler) document.addEventListener('keydown', this.undoRedoHandler)
if (this.state.boardId) { if (this.state.boardId) {
this.attachToBoard(this.state.boardId, this.state.viewId) this.attachToBoard(this.state.boardId, this.state.viewId)
} else { } else {
this.sync() this.sync()
} }
} }
componentWillUnmount() { componentWillUnmount() {
Utils.log(`boardPage.componentWillUnmount: ${this.state.boardId}`) Utils.log(`boardPage.componentWillUnmount: ${this.state.boardId}`)
this.workspaceListener.close() this.workspaceListener.close()
document.removeEventListener('keydown', this.undoRedoHandler) document.removeEventListener('keydown', this.undoRedoHandler)
} }
render() { render() {
const {workspaceTree} = this.state const {workspaceTree} = this.state
if (this.state.filterAnchorElement) { if (this.state.filterAnchorElement) {
const element = this.state.filterAnchorElement const element = this.state.filterAnchorElement
const bodyRect = document.body.getBoundingClientRect() const bodyRect = document.body.getBoundingClientRect()
const rect = element.getBoundingClientRect() const rect = element.getBoundingClientRect()
// Show at bottom-left of element // Show at bottom-left of element
const maxX = bodyRect.right - 420 - 100 const maxX = bodyRect.right - 420 - 100
const pageX = Math.min(maxX, rect.left - bodyRect.left) const pageX = Math.min(maxX, rect.left - bodyRect.left)
const pageY = rect.bottom - bodyRect.top const pageY = rect.bottom - bodyRect.top
ReactDOM.render( ReactDOM.render(
@ -129,85 +129,85 @@ export default class BoardPage extends React.Component<Props, State> {
Utils.getElementById('modal'), Utils.getElementById('modal'),
) )
} else { } else {
const modal = document.getElementById('modal') const modal = document.getElementById('modal')
if (modal) { if (modal) {
ReactDOM.render(<div/>, modal) ReactDOM.render(<div/>, modal)
} }
} }
Utils.log(`BoardPage.render ${this.state.boardTree?.board?.title}`) Utils.log(`BoardPage.render ${this.state.boardTree?.board?.title}`)
return ( return (
<div className='BoardPage'> <div className='BoardPage'>
<WorkspaceComponent <WorkspaceComponent
workspaceTree={workspaceTree} workspaceTree={workspaceTree}
boardTree={this.state.boardTree} boardTree={this.state.boardTree}
showView={(id, boardId) => { showView={(id, boardId) => {
this.showView(id, boardId) this.showView(id, boardId)
}} }}
showBoard={(id) => { showBoard={(id) => {
this.showBoard(id) this.showBoard(id)
}} }}
showFilter={(el) => { showFilter={(el) => {
this.showFilter(el) this.showFilter(el)
}} }}
setSearchText={(text) => { setSearchText={(text) => {
this.setSearchText(text) this.setSearchText(text)
}} }}
/> />
</div> </div>
) )
} }
private async attachToBoard(boardId: string, viewId?: string) { private async attachToBoard(boardId: string, viewId?: string) {
Utils.log(`attachToBoard: ${boardId}`) Utils.log(`attachToBoard: ${boardId}`)
this.sync(boardId, viewId) this.sync(boardId, viewId)
} }
async sync(boardId: string = this.state.boardId, viewId: string | undefined = this.state.viewId) { async sync(boardId: string = this.state.boardId, viewId: string | undefined = this.state.viewId) {
const {workspaceTree} = this.state const {workspaceTree} = this.state
Utils.log(`sync start: ${boardId}`) Utils.log(`sync start: ${boardId}`)
await workspaceTree.sync() await workspaceTree.sync()
const boardIds = workspaceTree.boards.map(o => o.id) const boardIds = workspaceTree.boards.map((o) => o.id)
this.workspaceListener.open(boardIds, async (blockId) => { this.workspaceListener.open(boardIds, async (blockId) => {
Utils.log(`workspaceListener.onChanged: ${blockId}`) Utils.log(`workspaceListener.onChanged: ${blockId}`)
this.sync() this.sync()
}) })
if (boardId) { if (boardId) {
const boardTree = new MutableBoardTree(boardId) const boardTree = new MutableBoardTree(boardId)
await boardTree.sync() await boardTree.sync()
// Default to first view // Default to first view
if (!viewId) { if (!viewId) {
viewId = boardTree.views[0].id viewId = boardTree.views[0].id
} }
boardTree.setActiveView(viewId) boardTree.setActiveView(viewId)
// TODO: Handle error (viewId not found) // TODO: Handle error (viewId not found)
this.setState({ this.setState({
...this.state, ...this.state,
boardTree, boardTree,
boardId, boardId,
viewId: boardTree.activeView.id, viewId: boardTree.activeView.id,
}) })
Utils.log(`sync complete: ${boardTree.board.id} (${boardTree.board.title})`) Utils.log(`sync complete: ${boardTree.board.id} (${boardTree.board.title})`)
} else { } else {
this.forceUpdate() this.forceUpdate()
} }
} }
// IPageController // IPageController
showBoard(boardId: string) { showBoard(boardId: string) {
const {boardTree} = this.state const {boardTree} = this.state
if (boardTree?.board?.id === boardId) { if (boardTree?.board?.id === boardId) {
return return
} }
const newUrl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?id=${encodeURIComponent(boardId)}` const newUrl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?id=${encodeURIComponent(boardId)}`
window.history.pushState({path: newUrl}, '', newUrl) window.history.pushState({path: newUrl}, '', newUrl)
this.attachToBoard(boardId) this.attachToBoard(boardId)
} }
@ -221,11 +221,11 @@ export default class BoardPage extends React.Component<Props, State> {
} }
const newUrl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?id=${encodeURIComponent(boardId)}&v=${encodeURIComponent(viewId)}` const newUrl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?id=${encodeURIComponent(boardId)}&v=${encodeURIComponent(viewId)}`
window.history.pushState({path: newUrl}, '', newUrl) window.history.pushState({path: newUrl}, '', newUrl)
} }
showFilter(anchorElement?: HTMLElement) { showFilter(anchorElement?: HTMLElement) {
this.setState({...this.state, filterAnchorElement: anchorElement}) this.setState({...this.state, filterAnchorElement: anchorElement})
} }
setSearchText(text?: string) { setSearchText(text?: string) {

View File

@ -1,13 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react' import React from 'react'
import { Archiver } from '../archiver'
import { MutableBoard } from '../blocks/board' import {Archiver} from '../archiver'
import {MutableBoard} from '../blocks/board'
import Button from '../components/button' import Button from '../components/button'
import octoClient from '../octoClient' import octoClient from '../octoClient'
import { IBlock } from '../blocks/block' import {IBlock} from '../blocks/block'
import { Utils } from '../utils' import {Utils} from '../utils'
type Props = {} type Props = {}
@ -28,39 +28,39 @@ export default class HomePage extends React.Component<Props, State> {
} }
loadBoards = async () => { loadBoards = async () => {
const boards = await octoClient.getBlocksWithType('board') const boards = await octoClient.getBlocksWithType('board')
this.setState({boards}) this.setState({boards})
} }
importClicked = async () => { importClicked = async () => {
Archiver.importFullArchive(() => { Archiver.importFullArchive(() => {
this.loadBoards() this.loadBoards()
}) })
} }
exportClicked = async () => { exportClicked = async () => {
Archiver.exportFullArchive() Archiver.exportFullArchive()
} }
addClicked = async () => { addClicked = async () => {
const board = new MutableBoard() const board = new MutableBoard()
await octoClient.insertBlock(board) await octoClient.insertBlock(board)
} }
render(): React.ReactNode { render(): React.ReactNode {
return ( return (
<div> <div>
<Button onClick={this.addClicked}>+ Add Board</Button> <Button onClick={this.addClicked}>+ Add Board</Button>
<br/> <br/>
<Button onClick={this.addClicked}>Import Archive</Button> <Button onClick={this.addClicked}>Import Archive</Button>
<br/> <br/>
<Button onClick={this.addClicked}>Export Archive</Button> <Button onClick={this.addClicked}>Export Archive</Button>
{this.state.boards.map((board) => ( {this.state.boards.map((board) => (
<p> <p>
<a href={`/board/${board.id}`}> <a href={`/board/${board.id}`}>
<span>{board.title}</span> <span>{board.title}</span>
<span>{Utils.displayDate(new Date(board.updateAt))}</span> <span>{Utils.displayDate(new Date(board.updateAt))}</span>
</a> </a>
</p> </p>
))} ))}
</div> </div>

View File

@ -12,280 +12,329 @@ import {Utils} from '../utils'
type Group = { option: IPropertyOption, cards: Card[] } type Group = { option: IPropertyOption, cards: Card[] }
interface BoardTree { interface BoardTree {
readonly board: Board readonly board: Board
readonly views: readonly BoardView[] readonly views: readonly BoardView[]
readonly cards: readonly Card[] readonly cards: readonly Card[]
readonly emptyGroupCards: readonly Card[] readonly emptyGroupCards: readonly Card[]
readonly groups: readonly Group[] readonly groups: readonly Group[]
readonly allBlocks: readonly IBlock[] readonly allBlocks: readonly IBlock[]
readonly activeView?: BoardView readonly activeView?: BoardView
readonly groupByProperty?: IPropertyTemplate readonly groupByProperty?: IPropertyTemplate
getSearchText(): string | undefined getSearchText(): string | undefined
} }
class MutableBoardTree implements BoardTree { class MutableBoardTree implements BoardTree {
board!: Board board!: Board
views: MutableBoardView[] = [] views: MutableBoardView[] = []
cards: Card[] = [] cards: Card[] = []
emptyGroupCards: Card[] = [] emptyGroupCards: Card[] = []
groups: Group[] = [] groups: Group[] = []
activeView?: MutableBoardView activeView?: MutableBoardView
groupByProperty?: IPropertyTemplate groupByProperty?: IPropertyTemplate
private searchText?: string private searchText?: string
private allCards: Card[] = [] private allCards: Card[] = []
get allBlocks(): IBlock[] { get allBlocks(): IBlock[] {
return [this.board, ...this.views, ...this.allCards] return [this.board, ...this.views, ...this.allCards]
} }
constructor(private boardId: string) { constructor(private boardId: string) {
} }
async sync() { async sync() {
const blocks = await octoClient.getSubtree(this.boardId) const blocks = await octoClient.getSubtree(this.boardId)
this.rebuild(OctoUtils.hydrateBlocks(blocks)) this.rebuild(OctoUtils.hydrateBlocks(blocks))
} }
private rebuild(blocks: IBlock[]) { private rebuild(blocks: IBlock[]) {
this.board = blocks.find(block => block.type === "board") as Board this.board = blocks.find((block) => block.type === 'board') as Board
this.views = blocks.filter(block => block.type === "view") as MutableBoardView[] this.views = blocks.filter((block) => block.type === 'view') as MutableBoardView[]
this.allCards = blocks.filter(block => block.type === "card") as Card[] this.allCards = blocks.filter((block) => block.type === 'card') as Card[]
this.cards = [] this.cards = []
this.ensureMinimumSchema() this.ensureMinimumSchema()
} }
private async ensureMinimumSchema() { private async ensureMinimumSchema() {
const { board } = this const {board} = this
let didChange = false let didChange = false
// At least one select property // At least one select property
const selectProperties = board.cardProperties.find(o => o.type === "select") const selectProperties = board.cardProperties.find((o) => o.type === 'select')
if (!selectProperties) { if (!selectProperties) {
const newBoard = new MutableBoard(board) const newBoard = new MutableBoard(board)
const property: IPropertyTemplate = { const property: IPropertyTemplate = {
id: Utils.createGuid(), id: Utils.createGuid(),
name: "Status", name: 'Status',
type: "select", type: 'select',
options: [] options: [],
} }
newBoard.cardProperties.push(property) newBoard.cardProperties.push(property)
this.board = newBoard this.board = newBoard
didChange = true didChange = true
} }
// At least one view // At least one view
if (this.views.length < 1) { if (this.views.length < 1) {
const view = new MutableBoardView() const view = new MutableBoardView()
view.parentId = board.id view.parentId = board.id
view.groupById = board.cardProperties.find(o => o.type === "select")?.id view.groupById = board.cardProperties.find((o) => o.type === 'select')?.id
this.views.push(view) this.views.push(view)
didChange = true didChange = true
} }
return didChange return didChange
} }
setActiveView(viewId: string) { setActiveView(viewId: string) {
this.activeView = this.views.find(o => o.id === viewId) this.activeView = this.views.find((o) => o.id === viewId)
if (!this.activeView) { if (!this.activeView) {
Utils.logError(`Cannot find BoardView: ${viewId}`) Utils.logError(`Cannot find BoardView: ${viewId}`)
this.activeView = this.views[0] this.activeView = this.views[0]
} }
// Fix missing group by (e.g. for new views) // Fix missing group by (e.g. for new views)
if (this.activeView.viewType === "board" && !this.activeView.groupById) { if (this.activeView.viewType === 'board' && !this.activeView.groupById) {
this.activeView.groupById = this.board.cardProperties.find(o => o.type === "select")?.id this.activeView.groupById = this.board.cardProperties.find((o) => o.type === 'select')?.id
} }
this.applyFilterSortAndGroup() this.applyFilterSortAndGroup()
} }
getSearchText(): string | undefined { getSearchText(): string | undefined {
return this.searchText return this.searchText
} }
setSearchText(text?: string) { setSearchText(text?: string) {
this.searchText = text this.searchText = text
this.applyFilterSortAndGroup() this.applyFilterSortAndGroup()
} }
applyFilterSortAndGroup() { applyFilterSortAndGroup() {
Utils.assert(this.allCards !== undefined) Utils.assert(this.allCards !== undefined)
this.cards = this.filterCards(this.allCards) this.cards = this.filterCards(this.allCards)
Utils.assert(this.cards !== undefined) Utils.assert(this.cards !== undefined)
this.cards = this.searchFilterCards(this.cards) this.cards = this.searchFilterCards(this.cards)
Utils.assert(this.cards !== undefined) Utils.assert(this.cards !== undefined)
this.cards = this.sortCards(this.cards) this.cards = this.sortCards(this.cards)
Utils.assert(this.cards !== undefined) Utils.assert(this.cards !== undefined)
if (this.activeView.groupById) { if (this.activeView.groupById) {
this.setGroupByProperty(this.activeView.groupById) this.setGroupByProperty(this.activeView.groupById)
} else { } else {
Utils.assert(this.activeView.viewType !== "board") Utils.assert(this.activeView.viewType !== 'board')
} }
Utils.assert(this.cards !== undefined) Utils.assert(this.cards !== undefined)
} }
private searchFilterCards(cards: Card[]): Card[] { private searchFilterCards(cards: Card[]): Card[] {
const searchText = this.searchText?.toLocaleLowerCase() const searchText = this.searchText?.toLocaleLowerCase()
if (!searchText) { return cards.slice() } if (!searchText) {
return cards.slice()
}
return cards.filter(card => { return cards.filter((card) => {
if (card.title?.toLocaleLowerCase().indexOf(searchText) !== -1) { return true } if (card.title?.toLocaleLowerCase().indexOf(searchText) !== -1) {
}) return true
} }
})
}
private setGroupByProperty(propertyId: string) { private setGroupByProperty(propertyId: string) {
const { board } = this const {board} = this
let property = board.cardProperties.find(o => o.id === propertyId) let property = board.cardProperties.find((o) => o.id === propertyId)
// TODO: Handle multi-select
if (!property || property.type !== "select") {
Utils.logError(`this.view.groupById card property not found: ${propertyId}`)
property = board.cardProperties.find(o => o.type === "select")
Utils.assertValue(property)
}
this.groupByProperty = property
this.groupCards() // TODO: Handle multi-select
} if (!property || property.type !== 'select') {
Utils.logError(`this.view.groupById card property not found: ${propertyId}`)
property = board.cardProperties.find((o) => o.type === 'select')
Utils.assertValue(property)
}
this.groupByProperty = property
private groupCards() { this.groupCards()
this.groups = [] }
const groupByPropertyId = this.groupByProperty.id private groupCards() {
this.groups = []
this.emptyGroupCards = this.cards.filter(o => { const groupByPropertyId = this.groupByProperty.id
const propertyValue = o.properties[groupByPropertyId]
return !propertyValue || !this.groupByProperty.options.find(option => option.value === propertyValue)
})
const propertyOptions = this.groupByProperty.options || [] this.emptyGroupCards = this.cards.filter((o) => {
for (const option of propertyOptions) { const propertyValue = o.properties[groupByPropertyId]
const cards = this.cards return !propertyValue || !this.groupByProperty.options.find((option) => option.value === propertyValue)
.filter(o => { })
const propertyValue = o.properties[groupByPropertyId]
return propertyValue && propertyValue === option.value
})
const group: Group = { const propertyOptions = this.groupByProperty.options || []
option, for (const option of propertyOptions) {
cards const cards = this.cards.
} filter((o) => {
const propertyValue = o.properties[groupByPropertyId]
return propertyValue && propertyValue === option.value
})
this.groups.push(group) const group: Group = {
} option,
} cards,
}
private filterCards(cards: Card[]): Card[] { this.groups.push(group)
const { board } = this }
const filterGroup = this.activeView?.filter }
if (!filterGroup) { return cards.slice() }
return CardFilter.applyFilterGroup(filterGroup, board.cardProperties, cards) private filterCards(cards: Card[]): Card[] {
} const {board} = this
const filterGroup = this.activeView?.filter
if (!filterGroup) {
return cards.slice()
}
private sortCards(cards: Card[]): Card[] { return CardFilter.applyFilterGroup(filterGroup, board.cardProperties, cards)
if (!this.activeView) { Utils.assertFailure(); return cards } }
const { board } = this
const { sortOptions } = this.activeView
let sortedCards: Card[] = []
if (sortOptions.length < 1) { private sortCards(cards: Card[]): Card[] {
Utils.log(`Default sort`) if (!this.activeView) {
sortedCards = cards.sort((a, b) => { Utils.assertFailure(); return cards
const aValue = a.title || "" }
const bValue = b.title || "" const {board} = this
const {sortOptions} = this.activeView
let sortedCards: Card[] = []
// Always put empty values at the bottom if (sortOptions.length < 1) {
if (aValue && !bValue) { return -1 } Utils.log('Default sort')
if (bValue && !aValue) { return 1 } sortedCards = cards.sort((a, b) => {
if (!aValue && !bValue) { return a.createAt - b.createAt } const aValue = a.title || ''
const bValue = b.title || ''
return a.createAt - b.createAt // Always put empty values at the bottom
}) if (aValue && !bValue) {
} else { return -1
sortOptions.forEach(sortOption => { }
if (sortOption.propertyId === "__name") { if (bValue && !aValue) {
Utils.log(`Sort by name`) return 1
sortedCards = cards.sort((a, b) => { }
const aValue = a.title || "" if (!aValue && !bValue) {
const bValue = b.title || "" return a.createAt - b.createAt
}
// Always put empty values at the bottom, newest last return a.createAt - b.createAt
if (aValue && !bValue) { return -1 } })
if (bValue && !aValue) { return 1 } } else {
if (!aValue && !bValue) { return a.createAt - b.createAt } sortOptions.forEach((sortOption) => {
if (sortOption.propertyId === '__name') {
Utils.log('Sort by name')
sortedCards = cards.sort((a, b) => {
const aValue = a.title || ''
const bValue = b.title || ''
let result = aValue.localeCompare(bValue) // Always put empty values at the bottom, newest last
if (sortOption.reversed) { result = -result } if (aValue && !bValue) {
return result return -1
}) }
} else { if (bValue && !aValue) {
const sortPropertyId = sortOption.propertyId return 1
const template = board.cardProperties.find(o => o.id === sortPropertyId) }
if (!template) { if (!aValue && !bValue) {
Utils.logError(`Missing template for property id: ${sortPropertyId}`) return a.createAt - b.createAt
return cards.slice() }
}
Utils.log(`Sort by ${template?.name}`)
sortedCards = cards.sort((a, b) => {
// Always put cards with no titles at the bottom
if (a.title && !b.title) { return -1 }
if (b.title && !a.title) { return 1 }
if (!a.title && !b.title) { return a.createAt - b.createAt }
const aValue = a.properties[sortPropertyId] || "" let result = aValue.localeCompare(bValue)
const bValue = b.properties[sortPropertyId] || "" if (sortOption.reversed) {
let result = 0 result = -result
if (template.type === "select") { }
// Always put empty values at the bottom return result
if (aValue && !bValue) { return -1 } })
if (bValue && !aValue) { return 1 } } else {
if (!aValue && !bValue) { return a.createAt - b.createAt } const sortPropertyId = sortOption.propertyId
const template = board.cardProperties.find((o) => o.id === sortPropertyId)
if (!template) {
Utils.logError(`Missing template for property id: ${sortPropertyId}`)
return cards.slice()
}
Utils.log(`Sort by ${template?.name}`)
sortedCards = cards.sort((a, b) => {
// Always put cards with no titles at the bottom
if (a.title && !b.title) {
return -1
}
if (b.title && !a.title) {
return 1
}
if (!a.title && !b.title) {
return a.createAt - b.createAt
}
// Sort by the option order (not alphabetically by value) const aValue = a.properties[sortPropertyId] || ''
const aOrder = template.options.findIndex(o => o.value === aValue) const bValue = b.properties[sortPropertyId] || ''
const bOrder = template.options.findIndex(o => o.value === bValue) let result = 0
if (template.type === 'select') {
// Always put empty values at the bottom
if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return a.createAt - b.createAt
}
result = aOrder - bOrder // Sort by the option order (not alphabetically by value)
} else if (template.type === "number" || template.type === "date") { const aOrder = template.options.findIndex((o) => o.value === aValue)
// Always put empty values at the bottom const bOrder = template.options.findIndex((o) => o.value === bValue)
if (aValue && !bValue) { return -1 }
if (bValue && !aValue) { return 1 }
if (!aValue && !bValue) { return a.createAt - b.createAt }
result = Number(aValue) - Number(bValue) result = aOrder - bOrder
} else if (template.type === "createdTime") { } else if (template.type === 'number' || template.type === 'date') {
result = a.createAt - b.createAt // Always put empty values at the bottom
} else if (template.type === "updatedTime") { if (aValue && !bValue) {
result = a.updateAt - b.updateAt return -1
} else { }
// Text-based sort if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return a.createAt - b.createAt
}
// Always put empty values at the bottom result = Number(aValue) - Number(bValue)
if (aValue && !bValue) { return -1 } } else if (template.type === 'createdTime') {
if (bValue && !aValue) { return 1 } result = a.createAt - b.createAt
if (!aValue && !bValue) { return a.createAt - b.createAt } } else if (template.type === 'updatedTime') {
result = a.updateAt - b.updateAt
} else {
// Text-based sort
result = aValue.localeCompare(bValue) // Always put empty values at the bottom
} if (aValue && !bValue) {
return -1
}
if (bValue && !aValue) {
return 1
}
if (!aValue && !bValue) {
return a.createAt - b.createAt
}
if (sortOption.reversed) { result = -result } result = aValue.localeCompare(bValue)
return result }
})
}
})
}
return sortedCards if (sortOption.reversed) {
} result = -result
}
return result
})
}
})
}
return sortedCards
}
} }
export { MutableBoardTree, BoardTree } export {MutableBoardTree, BoardTree}

View File

@ -1,40 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {Card} from '../blocks/card' import {Card} from '../blocks/card'
import { IOrderedBlock } from '../blocks/orderedBlock' import {IOrderedBlock} from '../blocks/orderedBlock'
import octoClient from '../octoClient' import octoClient from '../octoClient'
import {IBlock} from '../blocks/block' import {IBlock} from '../blocks/block'
import {OctoUtils} from '../octoUtils' import {OctoUtils} from '../octoUtils'
interface CardTree { interface CardTree {
readonly card: Card readonly card: Card
readonly comments: readonly IBlock[] readonly comments: readonly IBlock[]
readonly contents: readonly IOrderedBlock[] readonly contents: readonly IOrderedBlock[]
} }
class MutableCardTree implements CardTree { class MutableCardTree implements CardTree {
card: Card card: Card
comments: IBlock[] comments: IBlock[]
contents: IOrderedBlock[] contents: IOrderedBlock[]
constructor(private cardId: string) { constructor(private cardId: string) {
} }
async sync() { async sync() {
const blocks = await octoClient.getSubtree(this.cardId) const blocks = await octoClient.getSubtree(this.cardId)
this.rebuild(OctoUtils.hydrateBlocks(blocks)) this.rebuild(OctoUtils.hydrateBlocks(blocks))
} }
private rebuild(blocks: IBlock[]) { private rebuild(blocks: IBlock[]) {
this.card = blocks.find(o => o.id === this.cardId) as Card this.card = blocks.find((o) => o.id === this.cardId) as Card
this.comments = blocks this.comments = blocks.
.filter(block => block.type === "comment") filter((block) => block.type === 'comment').
.sort((a, b) => a.createAt - b.createAt) sort((a, b) => a.createAt - b.createAt)
const contentBlocks = blocks.filter(block => block.type === "text" || block.type === "image") as IOrderedBlock[] const contentBlocks = blocks.filter((block) => block.type === 'text' || block.type === 'image') as IOrderedBlock[]
this.contents = contentBlocks.sort((a, b) => a.order - b.order) this.contents = contentBlocks.sort((a, b) => a.order - b.order)
} }
} }
export { MutableCardTree, CardTree } export {MutableCardTree, CardTree}

View File

@ -7,29 +7,29 @@ import {OctoUtils} from '../octoUtils'
import {BoardView} from '../blocks/boardView' import {BoardView} from '../blocks/boardView'
interface WorkspaceTree { interface WorkspaceTree {
readonly boards: readonly Board[] readonly boards: readonly Board[]
readonly views: readonly BoardView[] readonly views: readonly BoardView[]
} }
class MutableWorkspaceTree { class MutableWorkspaceTree {
boards: Board[] = [] boards: Board[] = []
views: BoardView[] = [] views: BoardView[] = []
async sync() { async sync() {
const boards = await octoClient.getBlocksWithType("board") const boards = await octoClient.getBlocksWithType('board')
const views = await octoClient.getBlocksWithType("view") const views = await octoClient.getBlocksWithType('view')
this.rebuild( this.rebuild(
OctoUtils.hydrateBlocks(boards), OctoUtils.hydrateBlocks(boards),
OctoUtils.hydrateBlocks(views) OctoUtils.hydrateBlocks(views),
) )
} }
private rebuild(boards: IBlock[], views: IBlock[]) { private rebuild(boards: IBlock[], views: IBlock[]) {
this.boards = boards.filter(block => block.type === "board") as Board[] this.boards = boards.filter((block) => block.type === 'board') as Board[]
this.views = views.filter(block => block.type === "view") as BoardView[] this.views = views.filter((block) => block.type === 'view') as BoardView[]
} }
} }
// type WorkspaceTree = Readonly<MutableWorkspaceTree> // type WorkspaceTree = Readonly<MutableWorkspaceTree>
export { MutableWorkspaceTree, WorkspaceTree } export {MutableWorkspaceTree, WorkspaceTree}