Board templates
This commit is contained in:
parent
a704dde733
commit
02d26a800a
14 changed files with 214 additions and 37 deletions
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
.IconButton {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
background-color: rgba(var(--main-fg), 0.1);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
.Icon {
|
||||
|
|
|
@ -52,6 +52,7 @@
|
|||
.SubmenuTriangleIcon {
|
||||
fill: rgba(var(--main-fg), 0.7);
|
||||
}
|
||||
|
||||
.Icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
|
Loading…
Reference in a new issue