diff --git a/webapp/src/blocks/board.ts b/webapp/src/blocks/board.ts index 168dc9390..5f0da1476 100644 --- a/webapp/src/blocks/board.ts +++ b/webapp/src/blocks/board.ts @@ -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} diff --git a/webapp/src/blocks/card.ts b/webapp/src/blocks/card.ts index 9e0abef4a..f163ceeb5 100644 --- a/webapp/src/blocks/card.ts +++ b/webapp/src/blocks/card.ts @@ -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 diff --git a/webapp/src/components/sidebar.scss b/webapp/src/components/sidebar.scss index 36c2be062..9df4a3ed8 100644 --- a/webapp/src/components/sidebar.scss +++ b/webapp/src/components/sidebar.scss @@ -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; diff --git a/webapp/src/components/sidebar.tsx b/webapp/src/components/sidebar.tsx index 193edc5dc..40e5acab8 100644 --- a/webapp/src/components/sidebar.tsx +++ b/webapp/src/components/sidebar.tsx @@ -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 {
- + + + + + + + + + + + + {workspaceTree.boardTemplates.map((boardTemplate) => { + let displayName = boardTemplate.title || intl.formatMessage({id: 'Sidebar.untitled', defaultMessage: 'Untitled'}) + if (boardTemplate.icon) { + displayName = `${boardTemplate.icon} ${displayName}` + } + return ( + { + this.addBoardClicked(boardTemplate.id) + }} + rightIcon={ + + }/> + + { + this.props.showBoard(boardTemplate.id) + }} + /> + } + id='delete' + name={intl.formatMessage({id: 'Sidebar.delete-template', defaultMessage: 'Delete'})} + onClick={async () => { + await mutator.deleteBlock(boardTemplate, 'delete board template') + }} + /> + + + } + /> + ) + })} + + + + + +
@@ -266,18 +331,34 @@ class Sidebar extends React.Component { 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 { ) } + 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}) } diff --git a/webapp/src/components/viewHeader.tsx b/webapp/src/components/viewHeader.tsx index cb8868b52..2e749387d 100644 --- a/webapp/src/components/viewHeader.tsx +++ b/webapp/src/components/viewHeader.tsx @@ -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 { name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export Board Archive'})} onClick={() => Archiver.exportBoardTree(boardTree)} /> + @@ -464,6 +471,22 @@ class ViewHeader extends React.Component { 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) diff --git a/webapp/src/components/workspaceComponent.scss b/webapp/src/components/workspaceComponent.scss index 8a1ed13c8..5fe358c2e 100644 --- a/webapp/src/components/workspaceComponent.scss +++ b/webapp/src/components/workspaceComponent.scss @@ -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; + } + } } diff --git a/webapp/src/components/workspaceComponent.tsx b/webapp/src/components/workspaceComponent.tsx index e1bf0f0fa..6c5450de5 100644 --- a/webapp/src/components/workspaceComponent.tsx +++ b/webapp/src/components/workspaceComponent.tsx @@ -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 { activeBoardId={boardTree?.board.id} setLanguage={setLanguage} /> - {this.mainComponent()} +
+ {(boardTree?.board.isTemplate) && +
+ +
+ } + {this.mainComponent()} +
) return element diff --git a/webapp/src/mutator.ts b/webapp/src/mutator.ts index 8950d90b2..8dd46c92f 100644 --- a/webapp/src/mutator.ts +++ b/webapp/src/mutator.ts @@ -486,40 +486,36 @@ class Mutator { async duplicateCard(cardId: string, description = 'duplicate card', afterRedo?: (newBoardId: string) => Promise, beforeUndo?: () => Promise): 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, beforeUndo?: () => Promise): 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 diff --git a/webapp/src/octoUtils.tsx b/webapp/src/octoUtils.tsx index 70f3296e1..27b154590 100644 --- a/webapp/src/octoUtils.tsx +++ b/webapp/src/octoUtils.tsx @@ -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>] { + static duplicateBlockTree(blocks: IBlock[], rootBlockId: string): [MutableBlock[], MutableBlock, Readonly>] { const idMap: Record = {} 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] } } diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index 166b5000f..87e42b83b 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -143,7 +143,7 @@ export default class BoardPage extends React.Component { 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) diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index f291e3f59..9911b0010 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -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} diff --git a/webapp/src/viewModel/workspaceTree.ts b/webapp/src/viewModel/workspaceTree.ts index 0bd67001c..f62aeca81 100644 --- a/webapp/src/viewModel/workspaceTree.ts +++ b/webapp/src/viewModel/workspaceTree.ts @@ -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[] diff --git a/webapp/src/widgets/buttons/iconButton.scss b/webapp/src/widgets/buttons/iconButton.scss index 08f44c569..c3a769e35 100644 --- a/webapp/src/widgets/buttons/iconButton.scss +++ b/webapp/src/widgets/buttons/iconButton.scss @@ -1,7 +1,6 @@ .IconButton { height: 24px; width: 24px; - background-color: rgba(var(--main-fg), 0.1); padding: 0; margin: 0; .Icon { diff --git a/webapp/src/widgets/menu/menu.scss b/webapp/src/widgets/menu/menu.scss index 1c9339563..96772e2b2 100644 --- a/webapp/src/widgets/menu/menu.scss +++ b/webapp/src/widgets/menu/menu.scss @@ -52,6 +52,7 @@ .SubmenuTriangleIcon { fill: rgba(var(--main-fg), 0.7); } + .Icon { width: 16px; height: 16px;