1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-01-02 14:47:55 +02:00

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

View File

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

View File

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

View File

@ -4,21 +4,23 @@ import React from 'react'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl' import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {Archiver} from '../archiver' import {Archiver} from '../archiver'
import {IBlock} from '../blocks/block'
import {Board, MutableBoard} from '../blocks/board' import {Board, MutableBoard} from '../blocks/board'
import {BoardView, MutableBoardView} from '../blocks/boardView' import {BoardView, MutableBoardView} from '../blocks/boardView'
import mutator from '../mutator' import mutator from '../mutator'
import {darkTheme, lightTheme, mattermostTheme, setTheme} from '../theme' import {darkTheme, lightTheme, mattermostTheme, setTheme} from '../theme'
import {MutableBoardTree} from '../viewModel/boardTree'
import {WorkspaceTree} from '../viewModel/workspaceTree' import {WorkspaceTree} from '../viewModel/workspaceTree'
import Button from '../widgets/buttons/button' import Button from '../widgets/buttons/button'
import IconButton from '../widgets/buttons/iconButton' import IconButton from '../widgets/buttons/iconButton'
import DeleteIcon from '../widgets/icons/delete' import DeleteIcon from '../widgets/icons/delete'
import DisclosureTriangle from '../widgets/icons/disclosureTriangle'
import DotIcon from '../widgets/icons/dot' import DotIcon from '../widgets/icons/dot'
import DuplicateIcon from '../widgets/icons/duplicate' import DuplicateIcon from '../widgets/icons/duplicate'
import HamburgerIcon from '../widgets/icons/hamburger' import HamburgerIcon from '../widgets/icons/hamburger'
import HideSidebarIcon from '../widgets/icons/hideSidebar' import HideSidebarIcon from '../widgets/icons/hideSidebar'
import OptionsIcon from '../widgets/icons/options' import OptionsIcon from '../widgets/icons/options'
import ShowSidebarIcon from '../widgets/icons/showSidebar' import ShowSidebarIcon from '../widgets/icons/showSidebar'
import DisclosureTriangle from '../widgets/icons/disclosureTriangle'
import Menu from '../widgets/menu' import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper' import MenuWrapper from '../widgets/menuWrapper'
import './sidebar.scss' import './sidebar.scss'
@ -185,14 +187,77 @@ class Sidebar extends React.Component<Props, State> {
<br/> <br/>
<Button <MenuWrapper>
onClick={this.addBoardClicked} <Button>
> <FormattedMessage
<FormattedMessage id='Sidebar.add-board'
id='Sidebar.add-board' defaultMessage='+ Add Board'
defaultMessage='+ Add Board' />
/> </Button>
</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>
<div className='octo-spacer'/> <div className='octo-spacer'/>
@ -266,18 +331,34 @@ class Sidebar extends React.Component<Props, State> {
this.props.showView(view.id, board.id) this.props.showView(view.id, board.id)
} }
private addBoardClicked = async () => { private addBoardClicked = async (boardTemplateId?: string) => {
const {showBoard, intl} = this.props const {showBoard, intl} = this.props
const oldBoardId = this.props.activeBoardId const oldBoardId = this.props.activeBoardId
const board = new MutableBoard() let board: MutableBoard
const view = new MutableBoardView() const blocksToInsert: IBlock[] = []
view.viewType = 'board'
view.parentId = board.id if (boardTemplateId) {
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'}) 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( await mutator.insertBlocks(
[board, view], blocksToInsert,
'add board', 'add board',
async () => { async () => {
showBoard(board.id) 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 = () => { private hideClicked = () => {
this.setState({isHidden: true}) this.setState({isHidden: true})
} }

View File

@ -3,6 +3,8 @@
import React from 'react' import React from 'react'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl' import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {Utils} from '../utils'
import {Archiver} from '../archiver' import {Archiver} from '../archiver'
import {BlockIcons} from '../blockIcons' import {BlockIcons} from '../blockIcons'
import {IPropertyTemplate} from '../blocks/board' 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'})} name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export Board Archive'})}
onClick={() => Archiver.exportBoardTree(boardTree)} 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/> <Menu.Separator/>
@ -464,6 +471,22 @@ class ViewHeader extends React.Component<Props, State> {
return options 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) export default injectIntl(ViewHeader)

View File

@ -2,5 +2,17 @@
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
flex-direction: row; 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. // 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 {FormattedMessage} from 'react-intl'
import {Utils} from '../utils' import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree' import {BoardTree} from '../viewModel/boardTree'
@ -34,7 +35,17 @@ class WorkspaceComponent extends React.PureComponent<Props> {
activeBoardId={boardTree?.board.id} activeBoardId={boardTree?.board.id}
setLanguage={setLanguage} 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>) </div>)
return element 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]> { 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 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') const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
Utils.log(`duplicateCard: duplicating ${newBlocks.length} blocks`) 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}` newCard.title = `Copy of ${newCard.title}`
await this.insertBlocks( await this.insertBlocks(
newBlocks, newBlocks,
description, description,
async () => { async () => {
await afterRedo?.(newCardId) await afterRedo?.(newCard.id)
}, },
beforeUndo, 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]> { 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 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') const newBlocks = newBlocks1.filter((o) => o.type !== 'comment')
Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`) 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}` newBoard.title = `Copy of ${newBoard.title}`
await this.insertBlocks( await this.insertBlocks(
newBlocks, newBlocks,
description, description,
async () => { async () => {
await afterRedo?.(newBoardId) await afterRedo?.(newBoard.id)
}, },
beforeUndo, beforeUndo,
) )
return [newBlocks, newBoardId] return [newBlocks, newBoard.id]
} }
// Other methods // Other methods

View File

@ -88,7 +88,7 @@ class OctoUtils {
} }
// Creates a copy of the blocks with new ids and parentIDs // 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 idMap: Record<string, string> = {}
const newBlocks = blocks.map((block) => { const newBlocks = blocks.map((block) => {
const newBlock = this.hydrateBlock(block) const newBlock = this.hydrateBlock(block)
@ -97,7 +97,7 @@ class OctoUtils {
return newBlock return newBlock
}) })
const newRootBlockId = rootBlockId ? idMap[rootBlockId] : undefined const newRootBlockId = idMap[rootBlockId]
newBlocks.forEach((newBlock) => { newBlocks.forEach((newBlock) => {
// Note: Don't remap the parent of the new root block // Note: Don't remap the parent of the new root block
if (newBlock.id !== newRootBlockId && newBlock.parentId) { 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() const workspaceTree = new MutableWorkspaceTree()
await workspaceTree.sync() 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}) this.setState({workspaceTree})
// Listen to boards plus all blocks at root (Empty string for parentId) // Listen to boards plus all blocks at root (Empty string for parentId)

View File

@ -32,6 +32,7 @@ interface BoardTree {
orderedCards(): Card[] orderedCards(): Card[]
mutableCopy(): MutableBoardTree mutableCopy(): MutableBoardTree
templateCopy(): MutableBoardTree
} }
class MutableBoardTree implements BoardTree { class MutableBoardTree implements BoardTree {
@ -405,6 +406,14 @@ class MutableBoardTree implements BoardTree {
boardTree.incrementalUpdate(this.rawBlocks) boardTree.incrementalUpdate(this.rawBlocks)
return boardTree 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} export {MutableBoardTree, BoardTree, Group as BoardTreeGroup}

View File

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

View File

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

View File

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