Board templates

This commit is contained in:
Chen-I Lim 2020-11-17 14:11:04 -08:00
parent a704dde733
commit 02d26a800a
14 changed files with 214 additions and 37 deletions

View File

@ -1,5 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Utils} from '../utils'
import {IBlock} from '../blocks/block'
import {MutableBlock} from './block'
@ -35,7 +37,9 @@ interface IMutablePropertyTemplate extends IPropertyTemplate {
interface Board extends IBlock {
readonly icon: string
readonly isTemplate: boolean
readonly cardProperties: readonly IPropertyTemplate[]
duplicate(): MutableBoard
}
class MutableBoard extends MutableBlock {
@ -46,6 +50,13 @@ class MutableBoard extends MutableBlock {
this.fields.icon = value
}
get isTemplate(): boolean {
return Boolean(this.fields.isTemplate)
}
set isTemplate(value: boolean) {
this.fields.isTemplate = value
}
get cardProperties(): IMutablePropertyTemplate[] {
return this.fields.cardProperties as IPropertyTemplate[]
}
@ -72,6 +83,12 @@ class MutableBoard extends MutableBlock {
this.cardProperties = []
}
}
duplicate(): MutableBoard {
const card = new MutableBoard(this)
card.id = Utils.createGuid()
return card
}
}
export {Board, MutableBoard, PropertyType, IPropertyOption, IPropertyTemplate}

View File

@ -21,7 +21,7 @@ class MutableCard extends MutableBlock {
}
get isTemplate(): boolean {
return this.fields.isTemplate as boolean
return Boolean(this.fields.isTemplate)
}
set isTemplate(value: boolean) {
this.fields.isTemplate = value

View File

@ -42,7 +42,7 @@
font-weight: 600;
padding: 3px 20px;
margin-bottom: 5px;
.IconButton {
>.IconButton {
background-color: var(--sidebar-bg);
&:hover {
background-color: rgba(var(--sidebar-fg), 0.1);
@ -87,7 +87,7 @@
}
}
.IconButton {
>.IconButton {
background-color: var(--sidebar-bg);
&:hover {
background-color: rgba(var(--sidebar-fg), 0.1);
@ -127,6 +127,10 @@
flex-shrink: 0;
}
.Menu .OptionsIcon {
fill: unset;
}
.HideSidebarIcon {
stroke: rgba(var(--sidebar-fg), 0.5);
stroke-width: 6px;

View File

@ -4,21 +4,23 @@ import React from 'react'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {Archiver} from '../archiver'
import {IBlock} from '../blocks/block'
import {Board, MutableBoard} from '../blocks/board'
import {BoardView, MutableBoardView} from '../blocks/boardView'
import mutator from '../mutator'
import {darkTheme, lightTheme, mattermostTheme, setTheme} from '../theme'
import {MutableBoardTree} from '../viewModel/boardTree'
import {WorkspaceTree} from '../viewModel/workspaceTree'
import Button from '../widgets/buttons/button'
import IconButton from '../widgets/buttons/iconButton'
import DeleteIcon from '../widgets/icons/delete'
import DisclosureTriangle from '../widgets/icons/disclosureTriangle'
import DotIcon from '../widgets/icons/dot'
import DuplicateIcon from '../widgets/icons/duplicate'
import HamburgerIcon from '../widgets/icons/hamburger'
import HideSidebarIcon from '../widgets/icons/hideSidebar'
import OptionsIcon from '../widgets/icons/options'
import ShowSidebarIcon from '../widgets/icons/showSidebar'
import DisclosureTriangle from '../widgets/icons/disclosureTriangle'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import './sidebar.scss'
@ -185,14 +187,77 @@ class Sidebar extends React.Component<Props, State> {
<br/>
<Button
onClick={this.addBoardClicked}
>
<FormattedMessage
id='Sidebar.add-board'
defaultMessage='+ Add Board'
/>
</Button>
<MenuWrapper>
<Button>
<FormattedMessage
id='Sidebar.add-board'
defaultMessage='+ Add Board'
/>
</Button>
<Menu position='top'>
<Menu.Label>
<b>
<FormattedMessage
id='Sidebar.select-a-template'
defaultMessage='Select a template'
/>
</b>
</Menu.Label>
<Menu.Separator/>
{workspaceTree.boardTemplates.map((boardTemplate) => {
let displayName = boardTemplate.title || intl.formatMessage({id: 'Sidebar.untitled', defaultMessage: 'Untitled'})
if (boardTemplate.icon) {
displayName = `${boardTemplate.icon} ${displayName}`
}
return (
<Menu.Text
key={boardTemplate.id}
id={boardTemplate.id}
name={displayName}
onClick={() => {
this.addBoardClicked(boardTemplate.id)
}}
rightIcon={
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
id='edit'
name={intl.formatMessage({id: 'Sidebar.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
this.props.showBoard(boardTemplate.id)
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'Sidebar.delete-template', defaultMessage: 'Delete'})}
onClick={async () => {
await mutator.deleteBlock(boardTemplate, 'delete board template')
}}
/>
</Menu>
</MenuWrapper>
}
/>
)
})}
<Menu.Text
id='empty-template'
name={intl.formatMessage({id: 'Sidebar.empty-board', defaultMessage: 'Empty board'})}
onClick={this.addBoardClicked}
/>
<Menu.Text
id='add-template'
name={intl.formatMessage({id: 'Sidebar.add-template', defaultMessage: '+ New template'})}
onClick={this.addBoardTemplateClicked}
/>
</Menu>
</MenuWrapper>
</div>
<div className='octo-spacer'/>
@ -266,18 +331,34 @@ class Sidebar extends React.Component<Props, State> {
this.props.showView(view.id, board.id)
}
private addBoardClicked = async () => {
private addBoardClicked = async (boardTemplateId?: string) => {
const {showBoard, intl} = this.props
const oldBoardId = this.props.activeBoardId
const board = new MutableBoard()
const view = new MutableBoardView()
view.viewType = 'board'
view.parentId = board.id
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'})
let board: MutableBoard
const blocksToInsert: IBlock[] = []
if (boardTemplateId) {
const templateBoardTree = new MutableBoardTree(boardTemplateId)
await templateBoardTree.sync()
const newBoardTree = templateBoardTree.templateCopy()
board = newBoardTree.board
board.isTemplate = false
board.title = ''
blocksToInsert.push(...newBoardTree.allBlocks)
} else {
board = new MutableBoard()
blocksToInsert.push(board)
const view = new MutableBoardView()
view.viewType = 'board'
view.parentId = board.id
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'})
blocksToInsert.push(view)
}
await mutator.insertBlocks(
[board, view],
blocksToInsert,
'add board',
async () => {
showBoard(board.id)
@ -290,6 +371,24 @@ class Sidebar extends React.Component<Props, State> {
)
}
private addBoardTemplateClicked = async () => {
const {activeBoardId} = this.props
const boardTemplate = new MutableBoard()
boardTemplate.isTemplate = true
await mutator.insertBlock(
boardTemplate,
'add board template',
async () => {
this.props.showBoard(boardTemplate.id)
}, async () => {
if (activeBoardId) {
this.props.showBoard(activeBoardId)
}
},
)
}
private hideClicked = () => {
this.setState({isHidden: true})
}

View File

@ -3,6 +3,8 @@
import React from 'react'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {Utils} from '../utils'
import {Archiver} from '../archiver'
import {BlockIcons} from '../blockIcons'
import {IPropertyTemplate} from '../blocks/board'
@ -349,6 +351,11 @@ class ViewHeader extends React.Component<Props, State> {
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export Board Archive'})}
onClick={() => Archiver.exportBoardTree(boardTree)}
/>
<Menu.Text
id='newTemplateFromBoard'
name={intl.formatMessage({id: 'ViewHeader.new-template-from-board', defaultMessage: 'New template from board'})}
onClick={this.newTemplateFromBoardClicked}
/>
<Menu.Separator/>
@ -464,6 +471,22 @@ class ViewHeader extends React.Component<Props, State> {
return options
}
private newTemplateFromBoardClicked = async () => {
const {boardTree} = this.props
const newBoardTree = boardTree.templateCopy()
newBoardTree.board.isTemplate = true
newBoardTree.board.title = 'New Board Template'
Utils.log(`Created new board template: ${newBoardTree.board.id}`)
const blocksToInsert = newBoardTree.allBlocks
await mutator.insertBlocks(
blocksToInsert,
'create template from board',
)
}
}
export default injectIntl(ViewHeader)

View File

@ -2,5 +2,17 @@
flex: 1 1 auto;
display: flex;
flex-direction: row;
overflow: auto;
overflow: auto;
> .mainFrame {
flex: 1 1 auto;
display: flex;
flex-direction: column;
> .banner {
background-color: rgba(230, 220, 192, 0.9);
text-align: center;
padding: 10px;
}
}
}

View File

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
@ -34,7 +35,17 @@ class WorkspaceComponent extends React.PureComponent<Props> {
activeBoardId={boardTree?.board.id}
setLanguage={setLanguage}
/>
{this.mainComponent()}
<div className='mainFrame'>
{(boardTree?.board.isTemplate) &&
<div className='banner'>
<FormattedMessage
id='WorkspaceComponent.editing-board-template'
defaultMessage="You're editing a board template"
/>
</div>
}
{this.mainComponent()}
</div>
</div>)
return element

View File

@ -486,40 +486,36 @@ class Mutator {
async duplicateCard(cardId: string, description = 'duplicate card', afterRedo?: (newBoardId: string) => Promise<void>, beforeUndo?: () => Promise<void>): Promise<[IBlock[], string]> {
const blocks = await octoClient.getSubtree(cardId, 2)
const [newBlocks1, idMap] = OctoUtils.duplicateBlockTree(blocks, cardId)
const [newBlocks1, newCard] = OctoUtils.duplicateBlockTree(blocks, cardId)
const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
Utils.log(`duplicateCard: duplicating ${newBlocks.length} blocks`)
const newCardId = idMap[cardId]
const newCard = newBlocks.find((o) => o.id === newCardId)!
newCard.title = `Copy of ${newCard.title}`
await this.insertBlocks(
newBlocks,
description,
async () => {
await afterRedo?.(newCardId)
await afterRedo?.(newCard.id)
},
beforeUndo,
)
return [newBlocks, newCardId]
return [newBlocks, newCard.id]
}
async duplicateBoard(boardId: string, description = 'duplicate board', afterRedo?: (newBoardId: string) => Promise<void>, beforeUndo?: () => Promise<void>): Promise<[IBlock[], string]> {
const blocks = await octoClient.getSubtree(boardId, 3)
const [newBlocks1, idMap] = OctoUtils.duplicateBlockTree(blocks, boardId)
const [newBlocks1, newBoard] = OctoUtils.duplicateBlockTree(blocks, boardId)
const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`)
const newBoardId = idMap[boardId]
const newBoard = newBlocks.find((o) => o.id === newBoardId)!
newBoard.title = `Copy of ${newBoard.title}`
await this.insertBlocks(
newBlocks,
description,
async () => {
await afterRedo?.(newBoardId)
await afterRedo?.(newBoard.id)
},
beforeUndo,
)
return [newBlocks, newBoardId]
return [newBlocks, newBoard.id]
}
// Other methods

View File

@ -88,7 +88,7 @@ class OctoUtils {
}
// Creates a copy of the blocks with new ids and parentIDs
static duplicateBlockTree(blocks: IBlock[], rootBlockId?: string): [MutableBlock[], Readonly<Record<string, string>>] {
static duplicateBlockTree(blocks: IBlock[], rootBlockId: string): [MutableBlock[], MutableBlock, Readonly<Record<string, string>>] {
const idMap: Record<string, string> = {}
const newBlocks = blocks.map((block) => {
const newBlock = this.hydrateBlock(block)
@ -97,7 +97,7 @@ class OctoUtils {
return newBlock
})
const newRootBlockId = rootBlockId ? idMap[rootBlockId] : undefined
const newRootBlockId = idMap[rootBlockId]
newBlocks.forEach((newBlock) => {
// Note: Don't remap the parent of the new root block
if (newBlock.id !== newRootBlockId && newBlock.parentId) {
@ -112,7 +112,8 @@ class OctoUtils {
}
})
return [newBlocks, idMap]
const newRootBlock = newBlocks.find((block) => block.id === newRootBlockId)!
return [newBlocks, newRootBlock, idMap]
}
}

View File

@ -143,7 +143,7 @@ export default class BoardPage extends React.Component<Props, State> {
const workspaceTree = new MutableWorkspaceTree()
await workspaceTree.sync()
const boardIds = workspaceTree.boards.map((o) => o.id)
const boardIds = [...workspaceTree.boards.map((o) => o.id), ...workspaceTree.boardTemplates.map((o) => o.id)]
this.setState({workspaceTree})
// Listen to boards plus all blocks at root (Empty string for parentId)

View File

@ -32,6 +32,7 @@ interface BoardTree {
orderedCards(): Card[]
mutableCopy(): MutableBoardTree
templateCopy(): MutableBoardTree
}
class MutableBoardTree implements BoardTree {
@ -405,6 +406,14 @@ class MutableBoardTree implements BoardTree {
boardTree.incrementalUpdate(this.rawBlocks)
return boardTree
}
templateCopy(): MutableBoardTree {
const [newBlocks, newBoard] = OctoUtils.duplicateBlockTree(this.allBlocks, this.board.id)
const boardTree = new MutableBoardTree(newBoard.id)
boardTree.incrementalUpdate(newBlocks)
return boardTree
}
}
export {MutableBoardTree, BoardTree, Group as BoardTreeGroup}

View File

@ -8,6 +8,7 @@ import {OctoUtils} from '../octoUtils'
interface WorkspaceTree {
readonly boards: readonly Board[]
readonly boardTemplates: readonly Board[]
readonly views: readonly BoardView[]
mutableCopy(): MutableWorkspaceTree
@ -15,6 +16,7 @@ interface WorkspaceTree {
class MutableWorkspaceTree {
boards: Board[] = []
boardTemplates: Board[] = []
views: BoardView[] = []
private rawBlocks: IBlock[] = []
@ -37,7 +39,10 @@ class MutableWorkspaceTree {
}
private rebuild(blocks: IBlock[]) {
this.boards = blocks.filter((block) => block.type === 'board').
const allBoards = blocks.filter((block) => block.type === 'board') as Board[]
this.boards = allBoards.filter((block) => !block.isTemplate).
sort((a, b) => a.title.localeCompare(b.title)) as Board[]
this.boardTemplates = allBoards.filter((block) => block.isTemplate).
sort((a, b) => a.title.localeCompare(b.title)) as Board[]
this.views = blocks.filter((block) => block.type === 'view').
sort((a, b) => a.title.localeCompare(b.title)) as BoardView[]

View File

@ -1,7 +1,6 @@
.IconButton {
height: 24px;
width: 24px;
background-color: rgba(var(--main-fg), 0.1);
padding: 0;
margin: 0;
.Icon {

View File

@ -52,6 +52,7 @@
.SubmenuTriangleIcon {
fill: rgba(var(--main-fg), 0.7);
}
.Icon {
width: 16px;
height: 16px;