1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-07-15 23:54:29 +02:00

Card templates

This commit is contained in:
Chen-I Lim
2020-11-10 11:23:08 -08:00
parent 5b07bee7ec
commit 289f8f9d30
9 changed files with 241 additions and 27 deletions

View File

@ -1,12 +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 {Utils} from '../utils'
import {IBlock} from '../blocks/block' import {IBlock} from '../blocks/block'
import {MutableBlock} from './block' import {MutableBlock} from './block'
interface Card extends IBlock { interface Card extends IBlock {
readonly icon: string readonly icon: string
readonly isTemplate: boolean
readonly properties: Readonly<Record<string, string>> readonly properties: Readonly<Record<string, string>>
newCardFromTemplate(): MutableCard
} }
class MutableCard extends MutableBlock { class MutableCard extends MutableBlock {
@ -17,6 +20,13 @@ class MutableCard extends MutableBlock {
this.fields.icon = value this.fields.icon = value
} }
get isTemplate(): boolean {
return this.fields.isTemplate as boolean
}
set isTemplate(value: boolean) {
this.fields.isTemplate = value
}
get properties(): Record<string, string> { get properties(): Record<string, string> {
return this.fields.properties as Record<string, string> return this.fields.properties as Record<string, string>
} }
@ -30,6 +40,14 @@ class MutableCard extends MutableBlock {
this.properties = {...(block.fields?.properties || {})} this.properties = {...(block.fields?.properties || {})}
} }
newCardFromTemplate(): MutableCard {
const card = new MutableCard(this)
card.id = Utils.createGuid()
card.isTemplate = false
card.title = ''
return card
}
} }
export {MutableCard, Card} export {MutableCard, Card}

View File

@ -6,8 +6,10 @@ import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
import {BlockIcons} from '../blockIcons' import {BlockIcons} from '../blockIcons'
import {IPropertyOption, IPropertyTemplate} from '../blocks/board' import {IPropertyOption, IPropertyTemplate} from '../blocks/board'
import {IBlock} from '../blocks/block'
import {Card, MutableCard} from '../blocks/card' import {Card, MutableCard} from '../blocks/card'
import {BoardTree, BoardTreeGroup} from '../viewModel/boardTree' import {BoardTree, BoardTreeGroup} from '../viewModel/boardTree'
import {MutableCardTree} from '../viewModel/cardTree'
import {CardFilter} from '../cardFilter' import {CardFilter} from '../cardFilter'
import {Constants} from '../constants' import {Constants} from '../constants'
import mutator from '../mutator' import mutator from '../mutator'
@ -150,6 +152,10 @@ class BoardComponent extends React.Component<Props, State> {
showView={showView} showView={showView}
setSearchText={this.props.setSearchText} setSearchText={this.props.setSearchText}
addCard={() => this.addCard()} addCard={() => this.addCard()}
addCardFromTemplate={this.addCardFromTemplate}
addCardTemplate={() => this.addCardTemplate()}
editCardTemplate={this.editCardTemplate}
deleteCardTemplate={this.deleteCardTemplate}
withGroupBy={true} withGroupBy={true}
/> />
<div <div
@ -473,28 +479,80 @@ class BoardComponent extends React.Component<Props, State> {
} }
} }
private async addCard(groupByOptionId?: string): Promise<void> { private addCardFromTemplate = async (cardTemplate?: Card) => {
this.addCard(undefined, cardTemplate)
}
private async addCard(groupByOptionId?: string, cardTemplate?: Card): Promise<void> {
const {boardTree} = this.props const {boardTree} = this.props
const {activeView, board} = boardTree const {activeView, board} = boardTree
const card = new MutableCard() let card: MutableCard
let blocksToInsert: IBlock[]
if (cardTemplate) {
const templateCardTree = new MutableCardTree(cardTemplate.id)
await templateCardTree.sync()
const newCardTree = templateCardTree.duplicateFromTemplate()
card = newCardTree.card
blocksToInsert = [newCardTree.card, ...newCardTree.contents]
} else {
card = new MutableCard()
blocksToInsert = [card]
}
card.parentId = boardTree.board.id card.parentId = boardTree.board.id
card.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties) const propertiesThatMeetFilters = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
card.icon = BlockIcons.shared.randomIcon()
if (boardTree.groupByProperty) { if (boardTree.groupByProperty) {
if (groupByOptionId) { if (groupByOptionId) {
card.properties[boardTree.groupByProperty.id] = groupByOptionId propertiesThatMeetFilters[boardTree.groupByProperty.id] = groupByOptionId
} else { } else {
delete card.properties[boardTree.groupByProperty.id] delete propertiesThatMeetFilters[boardTree.groupByProperty.id]
} }
} }
await mutator.insertBlock(card, 'add card', async () => { card.properties = {...card.properties, ...propertiesThatMeetFilters}
card.icon = BlockIcons.shared.randomIcon()
await mutator.insertBlocks(
blocksToInsert,
'add card',
async () => {
this.setState({shownCard: card}) this.setState({shownCard: card})
},
async () => {
this.setState({shownCard: undefined})
},
)
}
private async addCardTemplate(groupByOptionId?: string): Promise<void> {
const {boardTree} = this.props
const {activeView, board} = boardTree
const cardTemplate = new MutableCard()
cardTemplate.isTemplate = true
cardTemplate.parentId = boardTree.board.id
cardTemplate.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
if (boardTree.groupByProperty) {
if (groupByOptionId) {
cardTemplate.properties[boardTree.groupByProperty.id] = groupByOptionId
} else {
delete cardTemplate.properties[boardTree.groupByProperty.id]
}
}
await mutator.insertBlock(cardTemplate, 'add card template', async () => {
this.setState({shownCard: cardTemplate})
}, async () => { }, async () => {
this.setState({shownCard: undefined}) this.setState({shownCard: undefined})
}) })
} }
private editCardTemplate = (cardTemplate: Card) => {
this.setState({shownCard: cardTemplate})
}
private deleteCardTemplate = (cardTemplate: Card) => {
mutator.deleteBlock(cardTemplate, 'delete card template')
}
private async propertyNameChanged(option: IPropertyOption, text: string): Promise<void> { private async propertyNameChanged(option: IPropertyOption, text: string): Promise<void> {
const {boardTree} = this.props const {boardTree} = this.props
@ -554,7 +612,6 @@ class BoardComponent extends React.Component<Props, State> {
const {draggedCards, draggedHeaderOption} = this const {draggedCards, draggedHeaderOption} = this
const optionId = option ? option.id : undefined const optionId = option ? option.id : undefined
Utils.assertValue(mutator)
Utils.assertValue(boardTree) Utils.assertValue(boardTree)
if (draggedCards.length > 0) { if (draggedCards.length > 0) {

View File

@ -2,6 +2,8 @@
// 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 {Card} from '../blocks/card' import {Card} from '../blocks/card'
import {BoardTree} from '../viewModel/boardTree' import {BoardTree} from '../viewModel/boardTree'
import mutator from '../mutator' import mutator from '../mutator'
@ -37,6 +39,14 @@ class CardDialog extends React.Component<Props> {
onClose={this.props.onClose} onClose={this.props.onClose}
toolsMenu={menu} toolsMenu={menu}
> >
{(this.props.card.isTemplate) &&
<div className='banner'>
<FormattedMessage
id='CardDialog.editing-template'
defaultMessage="You're editing a template"
/>
</div>
}
<CardDetail <CardDetail
boardTree={this.props.boardTree} boardTree={this.props.boardTree}
cardId={this.props.card.id} cardId={this.props.card.id}

View File

@ -24,6 +24,11 @@
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
> .banner {
background-color: rgba(230, 220, 192, 0.9);
text-align: center;
padding: 10px;
}
> .toolbar { > .toolbar {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -26,6 +26,8 @@ import './tableComponent.scss'
import {HorizontalGrip} from './horizontalGrip' import {HorizontalGrip} from './horizontalGrip'
import {MutableBoardView} from '../blocks/boardView' import {MutableBoardView} from '../blocks/boardView'
import {IBlock} from '../blocks/block'
import {MutableCardTree} from '../viewModel/cardTree'
type Props = { type Props = {
boardTree?: BoardTree boardTree?: BoardTree
@ -93,7 +95,11 @@ class TableComponent extends React.Component<Props, State> {
boardTree={boardTree} boardTree={boardTree}
showView={showView} showView={showView}
setSearchText={this.props.setSearchText} setSearchText={this.props.setSearchText}
addCard={this.addCard} addCard={this.addCardAndShow}
addCardFromTemplate={this.addCardFromTemplate}
addCardTemplate={this.addCardTemplate}
editCardTemplate={this.editCardTemplate}
deleteCardTemplate={this.deleteCardTemplate}
/> />
{/* Main content */} {/* Main content */}
@ -293,14 +299,34 @@ class TableComponent extends React.Component<Props, State> {
return Math.max(Constants.minColumnWidth, this.props.boardTree?.activeView?.columnWidths[templateId] || 0) return Math.max(Constants.minColumnWidth, this.props.boardTree?.activeView?.columnWidths[templateId] || 0)
} }
private addCard = async (show = false) => { private addCardAndShow = () => {
this.addCard(true)
}
private addCardFromTemplate = async (cardTemplate?: Card) => {
this.addCard(true, cardTemplate)
}
private addCard = async (show = false, cardTemplate?: Card) => {
const {boardTree} = this.props const {boardTree} = this.props
const card = new MutableCard() let card: MutableCard
let blocksToInsert: IBlock[]
if (cardTemplate) {
const templateCardTree = new MutableCardTree(cardTemplate.id)
await templateCardTree.sync()
const newCardTree = templateCardTree.duplicateFromTemplate()
card = newCardTree.card
blocksToInsert = [newCardTree.card, ...newCardTree.contents]
} else {
card = new MutableCard()
blocksToInsert = [card]
}
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.insertBlocks(
card, blocksToInsert,
'add card', 'add card',
async () => { async () => {
if (show) { if (show) {
@ -313,6 +339,30 @@ class TableComponent extends React.Component<Props, State> {
) )
} }
private addCardTemplate = async () => {
const {boardTree} = this.props
const cardTemplate = new MutableCard()
cardTemplate.isTemplate = true
cardTemplate.parentId = boardTree.board.id
cardTemplate.icon = BlockIcons.shared.randomIcon()
await mutator.insertBlock(
cardTemplate,
'add card',
async () => {
this.setState({shownCard: cardTemplate})
},
)
}
private editCardTemplate = (cardTemplate: Card) => {
this.setState({shownCard: cardTemplate})
}
private deleteCardTemplate = (cardTemplate: Card) => {
mutator.deleteBlock(cardTemplate, 'delete card template')
}
private async onDropToColumn(template: IPropertyTemplate) { private async onDropToColumn(template: IPropertyTemplate) {
const {draggedHeaderTemplate} = this const {draggedHeaderTemplate} = this
if (!draggedHeaderTemplate) { if (!draggedHeaderTemplate) {

View File

@ -6,7 +6,7 @@ import {injectIntl, IntlShape, FormattedMessage} from 'react-intl'
import {Archiver} from '../archiver' import {Archiver} from '../archiver'
import {ISortOption, MutableBoardView} from '../blocks/boardView' import {ISortOption, MutableBoardView} from '../blocks/boardView'
import {BlockIcons} from '../blockIcons' import {BlockIcons} from '../blockIcons'
import {MutableCard} from '../blocks/card' import {Card, MutableCard} from '../blocks/card'
import {IPropertyTemplate} from '../blocks/board' import {IPropertyTemplate} from '../blocks/board'
import {BoardTree} from '../viewModel/boardTree' import {BoardTree} from '../viewModel/boardTree'
import ViewMenu from '../components/viewMenu' import ViewMenu from '../components/viewMenu'
@ -29,15 +29,19 @@ import {Editable} from './editable'
import FilterComponent from './filterComponent' import FilterComponent from './filterComponent'
import './viewHeader.scss' import './viewHeader.scss'
import {sendFlashMessage} from './flashMessages'
import {Constants} from '../constants' import {Constants} from '../constants'
import DeleteIcon from '../widgets/icons/delete'
type Props = { type Props = {
boardTree?: BoardTree boardTree?: BoardTree
showView: (id: string) => void showView: (id: string) => void
setSearchText: (text: string) => void setSearchText: (text: string) => void
addCard: (show: boolean) => void addCard: () => void
addCardFromTemplate: (cardTemplate?: Card) => void
addCardTemplate: () => void
editCardTemplate: (cardTemplate: Card) => void
deleteCardTemplate: (cardTemplate: Card) => void
withGroupBy?: boolean withGroupBy?: boolean
intl: IntlShape intl: IntlShape
} }
@ -371,9 +375,10 @@ class ViewHeader extends React.Component<Props, State> {
/> />
</Menu> </Menu>
</MenuWrapper> </MenuWrapper>
<ButtonWithMenu <ButtonWithMenu
onClick={() => { onClick={() => {
this.props.addCard(true) this.props.addCard()
}} }}
text={( text={(
<FormattedMessage <FormattedMessage
@ -391,10 +396,56 @@ class ViewHeader extends React.Component<Props, State> {
/> />
</b> </b>
</Menu.Label> </Menu.Label>
<Menu.Separator/>
{boardTree.cardTemplates.map((cardTemplate) => {
return (
<Menu.Text <Menu.Text
id='example-template' key={cardTemplate.id}
name={intl.formatMessage({id: 'ViewHeader.sample-templte', defaultMessage: 'Sample template'})} id={cardTemplate.id}
onClick={() => sendFlashMessage({content: 'Not implemented yet', severity: 'low'})} name={cardTemplate.title || intl.formatMessage({id: 'ViewHeader.untitled', defaultMessage: 'Untitled'})}
onClick={() => {
this.props.addCardFromTemplate(cardTemplate)
}}
rightIcon={
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
id='edit'
name={intl.formatMessage({id: 'ViewHeader.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
this.props.editCardTemplate(cardTemplate)
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'ViewHeader.delete-template', defaultMessage: 'Delete'})}
onClick={() => {
this.props.deleteCardTemplate(cardTemplate)
}}
/>
</Menu>
</MenuWrapper>
}
/>
)
})}
<Menu.Text
id='empty-template'
name={intl.formatMessage({id: 'ViewHeader.empty-card', defaultMessage: 'Empty card'})}
onClick={() => {
this.props.addCard()
}}
/>
<Menu.Text
id='add-template'
name={intl.formatMessage({id: 'ViewHeader.add-template', defaultMessage: '+ New template'})}
onClick={() => this.props.addCardTemplate()}
/> />
</Menu> </Menu>
</ButtonWithMenu> </ButtonWithMenu>

View File

@ -19,6 +19,7 @@ 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 cardTemplates: readonly Card[]
readonly allCards: readonly Card[] readonly allCards: readonly Card[]
readonly visibleGroups: readonly Group[] readonly visibleGroups: readonly Group[]
readonly hiddenGroups: readonly Group[] readonly hiddenGroups: readonly Group[]
@ -37,6 +38,7 @@ class MutableBoardTree implements BoardTree {
board!: MutableBoard board!: MutableBoard
views: MutableBoardView[] = [] views: MutableBoardView[] = []
cards: MutableCard[] = [] cards: MutableCard[] = []
cardTemplates: MutableCard[] = []
visibleGroups: Group[] = [] visibleGroups: Group[] = []
hiddenGroups: Group[] = [] hiddenGroups: Group[] = []
@ -47,7 +49,7 @@ class MutableBoardTree implements BoardTree {
private searchText?: string private searchText?: string
allCards: MutableCard[] = [] allCards: MutableCard[] = []
get allBlocks(): IBlock[] { get allBlocks(): IBlock[] {
return [this.board, ...this.views, ...this.allCards] return [this.board, ...this.views, ...this.allCards, ...this.cardTemplates]
} }
constructor(private boardId: string) { constructor(private boardId: string) {
@ -71,7 +73,8 @@ class MutableBoardTree implements BoardTree {
private rebuild(blocks: IMutableBlock[]) { private rebuild(blocks: IMutableBlock[]) {
this.board = blocks.find((block) => block.type === 'board') as MutableBoard this.board = blocks.find((block) => block.type === 'board') as MutableBoard
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 MutableCard[] this.allCards = blocks.filter((block) => block.type === 'card' && !(block as Card).isTemplate) as MutableCard[]
this.cardTemplates = blocks.filter((block) => block.type === 'card' && (block as Card).isTemplate) as MutableCard[]
this.cards = [] this.cards = []
this.ensureMinimumSchema() this.ensureMinimumSchema()

View File

@ -1,10 +1,11 @@
// 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, MutableCard} 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, MutableBlock} from '../blocks/block'
import {OctoUtils} from '../octoUtils' import {OctoUtils} from '../octoUtils'
import {Utils} from '../utils'
interface CardTree { interface CardTree {
readonly card: Card readonly card: Card
@ -15,7 +16,7 @@ interface CardTree {
} }
class MutableCardTree implements CardTree { class MutableCardTree implements CardTree {
card: Card card: MutableCard
comments: IBlock[] = [] comments: IBlock[] = []
contents: IOrderedBlock[] = [] contents: IOrderedBlock[] = []
@ -40,7 +41,7 @@ class MutableCardTree implements CardTree {
} }
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 MutableCard
this.comments = blocks. this.comments = blocks.
filter((block) => block.type === 'comment'). filter((block) => block.type === 'comment').
@ -55,6 +56,19 @@ class MutableCardTree implements CardTree {
cardTree.incrementalUpdate(this.rawBlocks) cardTree.incrementalUpdate(this.rawBlocks)
return cardTree return cardTree
} }
duplicateFromTemplate(): MutableCardTree {
const card = this.card.newCardFromTemplate()
const contents: IOrderedBlock[] = this.contents.map((content) => {
const copy = MutableBlock.duplicate(content)
copy.parentId = card.id
return copy as IOrderedBlock
})
const cardTree = new MutableCardTree(card.id)
cardTree.incrementalUpdate([card, ...contents])
return cardTree
}
} }
export {MutableCardTree, CardTree} export {MutableCardTree, CardTree}

View File

@ -19,6 +19,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1;
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
@ -36,6 +38,10 @@
cursor: pointer; cursor: pointer;
touch-action: none; touch-action: none;
* {
display: flex;
}
&:hover { &:hover {
background: rgba(90, 90, 90, 0.1); background: rgba(90, 90, 90, 0.1);
} }