1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-03-03 15:32:14 +02:00

Readonly support in UI

This commit is contained in:
Chen-I Lim 2020-12-17 12:02:12 -08:00
parent 3e3f376582
commit f22527e650
24 changed files with 921 additions and 685 deletions

View File

@ -1,24 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-nested-callbacks */
/// <reference types="Cypress" />
describe('Create and delete board / card', () => {
const timestamp = new Date().toLocaleString();
const boardTitle = `Test Board (${timestamp})`;
const cardTitle = `Test Card (${timestamp})`;
it('Can create and delete a board and card', () => {
cy.visit('/');
cy.contains('+ Add Board').click({force: true});
cy.contains('Empty board').click({force: true});
cy.get('.BoardComponent').should('exist');
});
const timestamp = new Date().toLocaleString();
it('Can set the board title', () => {
// Board title
const boardTitle = `Test Board (${timestamp})`;
cy.get('.ViewTitle>.Editable.title').
type(boardTitle).
type('{enter}').
should('have.value', boardTitle);
});
it('Can rename the board view', () => {
// Rename board view
const boardViewTitle = `Test board (${timestamp})`;
cy.get('.ViewHeader').
@ -30,13 +37,16 @@ describe('Create and delete board / card', () => {
cy.get('.ViewHeader').
contains('.octo-editable', boardViewTitle).
should('exist');
});
it('Can create a card', () => {
// Create card
cy.get('.ViewHeader').contains('New').click();
cy.get('.CardDetail').should('exist');
});
it('Can set the card title', () => {
// Card title
const cardTitle = `Test Card (${timestamp})`;
cy.get('.CardDetail>.Editable.title').
type(cardTitle).
type('{enter}').
@ -44,7 +54,9 @@ describe('Create and delete board / card', () => {
// Close card
cy.get('.Dialog.dialog-back').click({force: true});
});
it('Can create a table view', () => {
// Create table view
// cy.intercept('POST', '/api/v1/blocks').as('insertBlocks');
cy.get('.ViewHeader').get('.DropdownIcon').first().parent().click();
@ -59,7 +71,9 @@ describe('Create and delete board / card', () => {
// Card should exist in table
cy.get(`.TableRow [value='${cardTitle}']`).should('exist');
});
it('Can rename the table view', () => {
// Rename table view
const tableViewTitle = `Test table (${timestamp})`;
cy.get('.ViewHeader').
@ -71,11 +85,24 @@ describe('Create and delete board / card', () => {
cy.get('.ViewHeader').
contains('.octo-editable', tableViewTitle).
should('exist');
});
it('Can sort the table', () => {
// Sort
cy.get('.ViewHeader').contains('Sort').click();
cy.get('.ViewHeader').contains('Sort').parent().contains('Name').click();
});
it('Can view the readonly board', () => {
cy.url().then((url) => {
const readonlyUrl = url + '&r=1';
cy.visit(readonlyUrl);
cy.get('.ViewTitle>.Editable.title').should('have.attr', 'readonly');
cy.visit(url);
});
});
it('Can delete the board', () => {
// Delete board
cy.get('.Sidebar .octo-sidebar-list').
contains(boardTitle).first().

View File

@ -7,7 +7,7 @@
border-radius: 5px;
align-items: center;
justify-content: center;
&:hover {
&:not(.readonly):hover {
background-color: rgba(var(--main-fg), 0.1);
}
&.size-s {

View File

@ -18,6 +18,7 @@ type Props = {
block: Board|Card
size?: 's' | 'm' | 'l'
intl: IntlShape
readonly?: boolean
}
class BlockIconSelector extends React.Component<Props> {
@ -41,10 +42,17 @@ class BlockIconSelector extends React.Component<Props> {
if (!block.icon) {
return null
}
let className = `octo-icon size-${size}`
if (this.props.readonly) {
className += ' readonly'
}
const iconElement = <div className={className}><span>{block.icon}</span></div>
return (
<div className='BlockIconSelector'>
{this.props.readonly && iconElement}
{!this.props.readonly &&
<MenuWrapper>
<div className={`octo-icon size-${size}`}><span>{block.icon}</span></div>
{iconElement}
<Menu>
<Menu.Text
id='random'
@ -67,6 +75,7 @@ class BlockIconSelector extends React.Component<Props> {
/>
</Menu>
</MenuWrapper>
}
</div>
)
}

View File

@ -26,6 +26,7 @@ type BoardCardProps = {
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void
onDrop: (e: React.DragEvent<HTMLDivElement>) => void
intl: IntlShape
readonly: boolean
}
type BoardCardState = {
@ -54,7 +55,7 @@ class BoardCard extends React.Component<BoardCardProps, BoardCardState> {
const element = (
<div
className={className}
draggable={true}
draggable={!this.props.readonly}
style={{opacity: this.state.isDragged ? 0.5 : 1}}
onClick={this.props.onClick}
onDragStart={(e) => {
@ -86,28 +87,30 @@ class BoardCard extends React.Component<BoardCardProps, BoardCardState> {
}
}}
>
<MenuWrapper
className='optionsMenu'
stopPropagationOnToggle={true}
>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'BoardCard.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deleteBlock(card, 'delete card')}
/>
<Menu.Text
icon={<DuplicateIcon/>}
id='duplicate'
name={intl.formatMessage({id: 'BoardCard.duplicate', defaultMessage: 'Duplicate'})}
onClick={() => {
mutator.duplicateCard(card.id)
}}
/>
</Menu>
</MenuWrapper>
{!this.props.readonly &&
<MenuWrapper
className='optionsMenu'
stopPropagationOnToggle={true}
>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'BoardCard.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deleteBlock(card, 'delete card')}
/>
<Menu.Text
icon={<DuplicateIcon/>}
id='duplicate'
name={intl.formatMessage({id: 'BoardCard.duplicate', defaultMessage: 'Duplicate'})}
onClick={() => {
mutator.duplicateCard(card.id)
}}
/>
</Menu>
</MenuWrapper>
}
<div className='octo-icontitle'>
{ card.icon ? <div className='octo-icon'>{card.icon}</div> : undefined }

View File

@ -36,6 +36,7 @@ type Props = {
showView: (id: string) => void
setSearchText: (text?: string) => void
intl: IntlShape
readonly: boolean
}
type State = {
@ -140,6 +141,7 @@ class BoardComponent extends React.Component<Props, State> {
cardId={this.state.shownCardId}
onClose={() => this.showCard(undefined)}
showCard={(cardId) => this.showCard(cardId)}
readonly={this.props.readonly}
/>
</RootPortal>}
@ -147,6 +149,7 @@ class BoardComponent extends React.Component<Props, State> {
<ViewTitle
key={board.id + board.title}
board={board}
readonly={this.props.readonly}
/>
<div className='octo-board'>
@ -159,6 +162,7 @@ class BoardComponent extends React.Component<Props, State> {
addCardTemplate={this.addCardTemplate}
editCardTemplate={this.editCardTemplate}
withGroupBy={true}
readonly={this.props.readonly}
/>
<div
className='octo-board-header'
@ -176,18 +180,21 @@ class BoardComponent extends React.Component<Props, State> {
id='BoardComponent.hidden-columns'
defaultMessage='Hidden columns'
/>
</div>}
</div>
}
<div className='octo-board-header-cell narrow'>
<Button
onClick={this.addGroupClicked}
>
<FormattedMessage
id='BoardComponent.add-a-group'
defaultMessage='+ Add a group'
/>
</Button>
</div>
{!this.props.readonly &&
<div className='octo-board-header-cell narrow'>
<Button
onClick={this.addGroupClicked}
>
<FormattedMessage
id='BoardComponent.add-a-group'
defaultMessage='+ Add a group'
/>
</Button>
</div>
}
</div>
{/* Main content */}
@ -205,16 +212,18 @@ class BoardComponent extends React.Component<Props, State> {
onDrop={() => this.onDropToColumn(group.option)}
>
{group.cards.map((card) => this.renderCard(card, visiblePropertyTemplates))}
<Button
onClick={() => {
this.addCard(group.option.id)
}}
>
<FormattedMessage
id='BoardComponent.neww'
defaultMessage='+ New'
/>
</Button>
{!this.props.readonly &&
<Button
onClick={() => {
this.addCard(group.option.id)
}}
>
<FormattedMessage
id='BoardComponent.neww'
defaultMessage='+ New'
/>
</Button>
}
</BoardColumn>
))}
@ -240,6 +249,7 @@ class BoardComponent extends React.Component<Props, State> {
card={card}
visiblePropertyTemplates={visiblePropertyTemplates}
key={card.id}
readonly={this.props.readonly}
isSelected={this.state.selectedCardIds.includes(card.id)}
onClick={(e) => {
this.cardClicked(e, card)
@ -277,7 +287,7 @@ class BoardComponent extends React.Component<Props, State> {
ref={ref}
className='octo-board-header-cell'
draggable={true}
draggable={!this.props.readonly}
onDragStart={() => {
this.draggedHeaderOption = group.option
}}
@ -320,21 +330,25 @@ class BoardComponent extends React.Component<Props, State> {
</div>
<Button>{`${group.cards.length}`}</Button>
<div className='octo-spacer'/>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='hide'
icon={<HideIcon/>}
name={intl.formatMessage({id: 'BoardComponent.hide', defaultMessage: 'Hide'})}
onClick={() => mutator.hideViewColumn(activeView, '')}
{!this.props.readonly &&
<>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='hide'
icon={<HideIcon/>}
name={intl.formatMessage({id: 'BoardComponent.hide', defaultMessage: 'Hide'})}
onClick={() => mutator.hideViewColumn(activeView, '')}
/>
</Menu>
</MenuWrapper>
<IconButton
icon={<AddIcon/>}
onClick={() => this.addCard(undefined)}
/>
</Menu>
</MenuWrapper>
<IconButton
icon={<AddIcon/>}
onClick={() => this.addCard(undefined)}
/>
</>
}
</div>
)
}
@ -346,7 +360,7 @@ class BoardComponent extends React.Component<Props, State> {
ref={ref}
className='octo-board-header-cell'
draggable={true}
draggable={!this.props.readonly}
onDragStart={() => {
this.draggedHeaderOption = group.option
}}
@ -380,39 +394,44 @@ class BoardComponent extends React.Component<Props, State> {
onChanged={(text) => {
this.propertyNameChanged(group.option, text)
}}
readonly={this.props.readonly}
/>
<Button>{`${group.cards.length}`}</Button>
<div className='octo-spacer'/>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='hide'
icon={<HideIcon/>}
name={intl.formatMessage({id: 'BoardComponent.hide', defaultMessage: 'Hide'})}
onClick={() => mutator.hideViewColumn(activeView, group.option.id)}
{!this.props.readonly &&
<>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='hide'
icon={<HideIcon/>}
name={intl.formatMessage({id: 'BoardComponent.hide', defaultMessage: 'Hide'})}
onClick={() => mutator.hideViewColumn(activeView, group.option.id)}
/>
<Menu.Text
id='delete'
icon={<DeleteIcon/>}
name={intl.formatMessage({id: 'BoardComponent.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty!, group.option)}
/>
<Menu.Separator/>
{Constants.menuColors.map((color) => (
<Menu.Color
key={color.id}
id={color.id}
name={color.name}
onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty!, group.option, color.id)}
/>
))}
</Menu>
</MenuWrapper>
<IconButton
icon={<AddIcon/>}
onClick={() => this.addCard(group.option.id)}
/>
<Menu.Text
id='delete'
icon={<DeleteIcon/>}
name={intl.formatMessage({id: 'BoardComponent.delete', defaultMessage: 'Delete'})}
onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty!, group.option)}
/>
<Menu.Separator/>
{Constants.menuColors.map((color) => (
<Menu.Color
key={color.id}
id={color.id}
name={color.name}
onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty!, group.option, color.id)}
/>
))}
</Menu>
</MenuWrapper>
<IconButton
icon={<AddIcon/>}
onClick={() => this.addCard(group.option.id)}
/>
</>
}
</div>
)
}
@ -457,7 +476,9 @@ class BoardComponent extends React.Component<Props, State> {
this.onDropToColumn(group.option)
}}
>
<MenuWrapper>
<MenuWrapper
disabled={this.props.readonly}
>
<div
key={group.option.id || 'empty'}
className={`octo-label ${group.option.color}`}

View File

@ -30,6 +30,7 @@ type Props = {
boardTree: BoardTree
cardTree: CardTree
intl: IntlShape
readonly: boolean
}
type State = {
@ -74,6 +75,7 @@ class CardDetail extends React.Component<Props, State> {
block={block}
card={card}
contents={cardTree.contents}
readonly={this.props.readonly}
/>
))}
</div>)
@ -81,20 +83,22 @@ class CardDetail extends React.Component<Props, State> {
contentElements = (<div className='octo-content'>
<div className='octo-block'>
<div className='octo-block-margin'/>
<MarkdownEditor
text=''
placeholderText='Add a description...'
onBlur={(text) => {
if (text) {
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.title = text
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add card text')
}
}}
/>
{!this.props.readonly &&
<MarkdownEditor
text=''
placeholderText='Add a description...'
onBlur={(text) => {
if (text) {
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.title = text
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add card text')
}
}}
/>
}
</div>
</div>)
}
@ -107,8 +111,9 @@ class CardDetail extends React.Component<Props, State> {
<BlockIconSelector
block={card}
size='l'
readonly={this.props.readonly}
/>
{!icon &&
{!this.props.readonly && !icon &&
<div className='add-buttons'>
<Button
onClick={() => {
@ -137,6 +142,7 @@ class CardDetail extends React.Component<Props, State> {
}
}}
onCancel={() => this.setState({title: this.props.cardTree.card.title})}
readonly={this.props.readonly}
/>
{/* Property list */}
@ -148,19 +154,22 @@ class CardDetail extends React.Component<Props, State> {
key={propertyTemplate.id}
className='octo-propertyrow'
>
<MenuWrapper>
<div className='octo-propertyname'><Button>{propertyTemplate.name}</Button></div>
<PropertyMenu
propertyId={propertyTemplate.id}
propertyName={propertyTemplate.name}
propertyType={propertyTemplate.type}
onNameChanged={(newName: string) => mutator.renameProperty(board, propertyTemplate.id, newName)}
onTypeChanged={(newType: PropertyType) => mutator.changePropertyType(boardTree, propertyTemplate, newType)}
onDelete={(id: string) => mutator.deleteProperty(boardTree, id)}
/>
</MenuWrapper>
{this.props.readonly && <div className='octo-propertyname'>{propertyTemplate.name}</div>}
{!this.props.readonly &&
<MenuWrapper>
<div className='octo-propertyname'><Button>{propertyTemplate.name}</Button></div>
<PropertyMenu
propertyId={propertyTemplate.id}
propertyName={propertyTemplate.name}
propertyType={propertyTemplate.type}
onNameChanged={(newName: string) => mutator.renameProperty(board, propertyTemplate.id, newName)}
onTypeChanged={(newType: PropertyType) => mutator.changePropertyType(boardTree, propertyTemplate, newType)}
onDelete={(id: string) => mutator.deleteProperty(boardTree, id)}
/>
</MenuWrapper>
}
<PropertyValueElement
readOnly={false}
readOnly={this.props.readonly}
card={card}
boardTree={boardTree}
propertyTemplate={propertyTemplate}
@ -170,29 +179,35 @@ class CardDetail extends React.Component<Props, State> {
)
})}
<div className='octo-propertyname add-property'>
<Button
onClick={async () => {
// TODO: Show UI
await mutator.insertPropertyTemplate(boardTree)
}}
>
<FormattedMessage
id='CardDetail.add-property'
defaultMessage='+ Add a property'
/>
</Button>
</div>
{!this.props.readonly &&
<div className='octo-propertyname add-property'>
<Button
onClick={async () => {
// TODO: Show UI
await mutator.insertPropertyTemplate(boardTree)
}}
>
<FormattedMessage
id='CardDetail.add-property'
defaultMessage='+ Add a property'
/>
</Button>
</div>
}
</div>
{/* Comments */}
<hr/>
<CommentsList
comments={comments}
cardId={card.id}
/>
<hr/>
{!this.props.readonly &&
<>
<hr/>
<CommentsList
comments={comments}
cardId={card.id}
/>
<hr/>
</>
}
</div>
{/* Content blocks */}
@ -201,38 +216,40 @@ class CardDetail extends React.Component<Props, State> {
{contentElements}
</div>
<div className='CardDetail content add-content'>
<MenuWrapper>
<Button>
<FormattedMessage
id='CardDetail.add-content'
defaultMessage='Add content'
/>
</Button>
<Menu position='top'>
<Menu.Text
id='text'
name={intl.formatMessage({id: 'CardDetail.text', defaultMessage: 'Text'})}
onClick={() => {
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add text')
}}
/>
<Menu.Text
id='image'
name={intl.formatMessage({id: 'CardDetail.image', defaultMessage: 'Image'})}
onClick={() => Utils.selectLocalFile(
(file) => mutator.createImageBlock(card, file, (this.props.cardTree.contents.length + 1) * 1000),
'.jpg,.jpeg,.png',
)}
/>
{!this.props.readonly &&
<div className='CardDetail content add-content'>
<MenuWrapper>
<Button>
<FormattedMessage
id='CardDetail.add-content'
defaultMessage='Add content'
/>
</Button>
<Menu position='top'>
<Menu.Text
id='text'
name={intl.formatMessage({id: 'CardDetail.text', defaultMessage: 'Text'})}
onClick={() => {
const block = new MutableTextBlock()
block.parentId = card.id
block.rootId = card.rootId
block.order = (this.props.cardTree.contents.length + 1) * 1000
mutator.insertBlock(block, 'add text')
}}
/>
<Menu.Text
id='image'
name={intl.formatMessage({id: 'CardDetail.image', defaultMessage: 'Image'})}
onClick={() => Utils.selectLocalFile(
(file) => mutator.createImageBlock(card, file, (this.props.cardTree.contents.length + 1) * 1000),
'.jpg,.jpeg,.png',
)}
/>
</Menu>
</MenuWrapper>
</div>
</Menu>
</MenuWrapper>
</div>
}
</>
)
}

View File

@ -20,6 +20,7 @@ type Props = {
onClose: () => void
showCard: (cardId?: string) => void
intl: IntlShape
readonly: boolean
}
type State = {
@ -106,7 +107,7 @@ class CardDialog extends React.Component<Props, State> {
return (
<Dialog
onClose={this.props.onClose}
toolsMenu={menu}
toolsMenu={!this.props.readonly && menu}
>
{(cardTree?.card.isTemplate) &&
<div className='banner'>
@ -120,6 +121,7 @@ class CardDialog extends React.Component<Props, State> {
<CardDetail
boardTree={this.props.boardTree}
cardTree={this.state.cardTree}
readonly={this.props.readonly}
/>
}
{(!this.state.cardTree && this.state.syncComplete) &&

View File

@ -29,6 +29,7 @@ type Props = {
block: IOrderedBlock
card: IBlock
contents: readonly IOrderedBlock[]
readonly: boolean
}
class ContentBlock extends React.PureComponent<Props> {
@ -44,89 +45,91 @@ class ContentBlock extends React.PureComponent<Props> {
return (
<div className='ContentBlock octo-block'>
<div className='octo-block-margin'>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
{index > 0 &&
<Menu.Text
id='moveUp'
name='Move up'
icon={<SortUpIcon/>}
onClick={() => {
const previousBlock = contents[index - 1]
const newOrder = OctoUtils.getOrderBefore(previousBlock, contents)
Utils.log(`moveUp ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move up')
}}
/>}
{index < (contents.length - 1) &&
<Menu.Text
id='moveDown'
name='Move down'
icon={<SortDownIcon/>}
onClick={() => {
const nextBlock = contents[index + 1]
const newOrder = OctoUtils.getOrderAfter(nextBlock, contents)
Utils.log(`moveDown ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move down')
}}
/>}
<Menu.SubMenu
id='insertAbove'
name='Insert above'
icon={<AddIcon/>}
>
<Menu.Text
id='text'
name='Text'
icon={<TextIcon/>}
onClick={() => {
const newBlock = new MutableTextBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
{!this.props.readonly &&
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
{index > 0 &&
<Menu.Text
id='moveUp'
name='Move up'
icon={<SortUpIcon/>}
onClick={() => {
const previousBlock = contents[index - 1]
const newOrder = OctoUtils.getOrderBefore(previousBlock, contents)
Utils.log(`moveUp ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move up')
}}
/>}
{index < (contents.length - 1) &&
<Menu.Text
id='moveDown'
name='Move down'
icon={<SortDownIcon/>}
onClick={() => {
const nextBlock = contents[index + 1]
const newOrder = OctoUtils.getOrderAfter(nextBlock, contents)
Utils.log(`moveDown ${newOrder}`)
mutator.changeOrder(block, newOrder, 'move down')
}}
/>}
<Menu.SubMenu
id='insertAbove'
name='Insert above'
icon={<AddIcon/>}
>
<Menu.Text
id='text'
name='Text'
icon={<TextIcon/>}
onClick={() => {
const newBlock = new MutableTextBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text')
}}
/>
<Menu.Text
id='image'
name='Image'
icon={<ImageIcon/>}
onClick={() => {
Utils.selectLocalFile(
(file) => {
mutator.createImageBlock(card, file, OctoUtils.getOrderBefore(block, contents))
},
'.jpg,.jpeg,.png')
}}
/>
<Menu.Text
id='divider'
name='Divider'
icon={<DividerIcon/>}
onClick={() => {
const newBlock = new MutableDividerBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text')
}}
/>
<Menu.Text
id='image'
name='Image'
icon={<ImageIcon/>}
onClick={() => {
Utils.selectLocalFile(
(file) => {
mutator.createImageBlock(card, file, OctoUtils.getOrderBefore(block, contents))
},
'.jpg,.jpeg,.png')
}}
/>
<Menu.Text
id='divider'
name='Divider'
icon={<DividerIcon/>}
onClick={() => {
const newBlock = new MutableDividerBlock()
newBlock.parentId = card.id
newBlock.rootId = card.rootId
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text')
}}
// TODO: Handle need to reorder all blocks
newBlock.order = OctoUtils.getOrderBefore(block, contents)
Utils.log(`insert block ${block.id}, order: ${block.order}`)
mutator.insertBlock(newBlock, 'insert card text')
}}
/>
</Menu.SubMenu>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name='Delete'
onClick={() => mutator.deleteBlock(block)}
/>
</Menu.SubMenu>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name='Delete'
onClick={() => mutator.deleteBlock(block)}
/>
</Menu>
</MenuWrapper>
</Menu>
</MenuWrapper>
}
</div>
{block.type === 'text' &&
<MarkdownEditor
@ -136,6 +139,7 @@ class ContentBlock extends React.PureComponent<Props> {
Utils.log(`change text ${block.id}, ${text}`)
mutator.changeTitle(block, text, 'edit card text')
}}
readonly={this.props.readonly}
/>}
{block.type === 'divider' && <div className='divider'/>}
{block.type === 'image' &&

View File

@ -47,20 +47,23 @@ export default class Dialog extends React.PureComponent<Props> {
}}
>
<div className='dialog' >
{toolsMenu &&
<div className='toolbar'>
<IconButton
onClick={this.closeClicked}
icon={<CloseIcon/>}
title={'Close dialog'}
className='hideOnWidescreen'
/>
<div className='octo-spacer'/>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
{toolsMenu}
</MenuWrapper>
</div>}
{toolsMenu &&
<>
<IconButton
onClick={this.closeClicked}
icon={<CloseIcon/>}
title={'Close dialog'}
className='hideOnWidescreen'
/>
<div className='octo-spacer'/>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
{toolsMenu}
</MenuWrapper>
</>
}
</div>
{this.props.children}
</div>
</div>

View File

@ -13,6 +13,7 @@ type Props = {
isMarkdown: boolean
isMultiline: boolean
allowEmpty: boolean
readonly?: boolean
onFocus?: () => void
onBlur?: () => void
@ -92,7 +93,7 @@ class Editable extends React.PureComponent<Props> {
<div
ref={this.elementRef}
className={className}
contentEditable={true}
contentEditable={!this.props.readonly}
suppressContentEditableWarning={true}
style={initialStyle}
placeholder={placeholderText}
@ -100,6 +101,10 @@ class Editable extends React.PureComponent<Props> {
dangerouslySetInnerHTML={{__html: html}}
onFocus={() => {
if (this.props.readonly) {
return
}
if (this.elementRef.current) {
this.elementRef.current.innerText = this.text
this.elementRef.current.style.color = style?.color || ''
@ -112,6 +117,10 @@ class Editable extends React.PureComponent<Props> {
}}
onBlur={async () => {
if (this.props.readonly) {
return
}
if (this.elementRef.current) {
const newText = this.elementRef.current.innerText
const oldText = this.props.text || ''
@ -134,6 +143,10 @@ class Editable extends React.PureComponent<Props> {
}}
onKeyDown={(e) => {
if (this.props.readonly) {
return
}
if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC
e.stopPropagation()
this.elementRef.current?.blur()

View File

@ -12,6 +12,7 @@ type Props = {
placeholderText?: string
uniqueId?: string
className?: string
readonly?: boolean
onChange?: (text: string) => void
onFocus?: () => void
@ -28,7 +29,7 @@ class MarkdownEditor extends React.Component<Props, State> {
}
get text(): string {
return this.elementRef.current!.state.value
return this.elementRef.current!.state.value || ''
}
set text(value: string) {
this.elementRef.current!.setState({value})
@ -91,7 +92,7 @@ class MarkdownEditor extends React.Component<Props, State> {
style={{display: this.state.isEditing ? 'none' : undefined}}
dangerouslySetInnerHTML={{__html: html}}
onClick={() => {
if (!this.state.isEditing) {
if (!this.props.readonly && !this.state.isEditing) {
this.showEditor()
}
}}

View File

@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import mutator from '../mutator'
import {BoardTree} from '../viewModel/boardTree'
import ButtonWithMenu from '../widgets/buttons/buttonWithMenu'
import IconButton from '../widgets/buttons/iconButton'
import CardIcon from '../widgets/icons/card'
import DeleteIcon from '../widgets/icons/delete'
import OptionsIcon from '../widgets/icons/options'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
type Props = {
boardTree: BoardTree
addCard: () => void
addCardFromTemplate: (cardTemplateId: string) => void
addCardTemplate: () => void
editCardTemplate: (cardTemplateId: string) => void
intl: IntlShape
}
class NewCardButton extends React.PureComponent<Props> {
render(): JSX.Element {
const {intl, boardTree} = this.props
return (
<ButtonWithMenu
onClick={() => {
this.props.addCard()
}}
text={(
<FormattedMessage
id='ViewHeader.new'
defaultMessage='New'
/>
)}
>
<Menu position='left'>
{boardTree.cardTemplates.length > 0 && <>
<Menu.Label>
<b>
<FormattedMessage
id='ViewHeader.select-a-template'
defaultMessage='Select a template'
/>
</b>
</Menu.Label>
<Menu.Separator/>
</>}
{boardTree.cardTemplates.map((cardTemplate) => {
const displayName = cardTemplate.title || intl.formatMessage({id: 'ViewHeader.untitled', defaultMessage: 'Untitled'})
return (
<Menu.Text
key={cardTemplate.id}
id={cardTemplate.id}
name={displayName}
icon={<div className='Icon'>{cardTemplate.icon}</div>}
onClick={() => {
this.props.addCardFromTemplate(cardTemplate.id)
}}
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.id)
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'ViewHeader.delete-template', defaultMessage: 'Delete'})}
onClick={async () => {
await mutator.deleteBlock(cardTemplate, 'delete card template')
}}
/>
</Menu>
</MenuWrapper>
}
/>
)
})}
<Menu.Text
id='empty-template'
name={intl.formatMessage({id: 'ViewHeader.empty-card', defaultMessage: 'Empty card'})}
icon={<CardIcon/>}
onClick={() => {
this.props.addCard()
}}
/>
<Menu.Text
id='add-template'
name={intl.formatMessage({id: 'ViewHeader.add-template', defaultMessage: '+ New template'})}
onClick={() => this.props.addCardTemplate()}
/>
</Menu>
</ButtonWithMenu>
)
}
}
export default injectIntl(NewCardButton)

View File

@ -50,7 +50,7 @@ export default class PropertyValueElement extends React.Component<Props, State>
}
if (propertyTemplate.type === 'select') {
let className = 'octo-propertyvalue'
let className = 'octo-propertyvalue octo-label'
if (!displayValue) {
className += ' empty'
}

View File

@ -55,6 +55,8 @@
.octo-propertyvalue {
line-height: 17px;
overflow: hidden;
text-overflow: ellipsis;
}
.octo-editable,

View File

@ -29,6 +29,7 @@ type Props = {
showView: (id: string) => void
setSearchText: (text?: string) => void
intl: IntlShape
readonly: boolean
}
type State = {
@ -80,12 +81,14 @@ class TableComponent extends React.Component<Props, State> {
cardId={this.state.shownCardId}
onClose={() => this.showCard(undefined)}
showCard={(cardId) => this.showCard(cardId)}
readonly={this.props.readonly}
/>
</RootPortal>}
<div className='octo-frame'>
<ViewTitle
key={board.id + board.title}
board={board}
readonly={this.props.readonly}
/>
<div className='octo-table'>
@ -97,6 +100,7 @@ class TableComponent extends React.Component<Props, State> {
addCardFromTemplate={this.addCardFromTemplate}
addCardTemplate={this.addCardTemplate}
editCardTemplate={this.editCardTemplate}
readonly={this.props.readonly}
/>
{/* Main content */}
@ -115,10 +119,11 @@ class TableComponent extends React.Component<Props, State> {
className='octo-table-cell title-cell header-cell'
style={{overflow: 'unset', width: this.columnWidth(Constants.titleColumnId)}}
>
<MenuWrapper>
<MenuWrapper
disabled={this.props.readonly}
>
<div
className='octo-label'
style={{cursor: 'pointer'}}
>
<FormattedMessage
id='TableComponent.name'
@ -134,32 +139,34 @@ class TableComponent extends React.Component<Props, State> {
<div className='octo-spacer'/>
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (titleRef.current) {
titleRef.current.style.width = `${newWidth}px`
}
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (titleRef.current) {
titleRef.current.style.width = `${newWidth}px`
}
{!this.props.readonly &&
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (titleRef.current) {
titleRef.current.style.width = `${newWidth}px`
}
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (titleRef.current) {
titleRef.current.style.width = `${newWidth}px`
}
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[Constants.titleColumnId]) {
columnWidths[Constants.titleColumnId] = newWidth
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[Constants.titleColumnId]) {
columnWidths[Constants.titleColumnId] = newWidth
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
/>
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
/>
}
</div>
{/* Table header row */}
@ -199,11 +206,12 @@ class TableComponent extends React.Component<Props, State> {
this.onDropToColumn(template)
}}
>
<MenuWrapper>
<MenuWrapper
disabled={this.props.readonly}
>
<div
className='octo-label'
style={{cursor: 'pointer'}}
draggable={true}
draggable={!this.props.readonly}
onDragStart={() => {
this.draggedHeaderTemplate = template
}}
@ -222,32 +230,34 @@ class TableComponent extends React.Component<Props, State> {
<div className='octo-spacer'/>
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (headerRef.current) {
headerRef.current.style.width = `${newWidth}px`
}
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (headerRef.current) {
headerRef.current.style.width = `${newWidth}px`
}
{!this.props.readonly &&
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (headerRef.current) {
headerRef.current.style.width = `${newWidth}px`
}
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (headerRef.current) {
headerRef.current.style.width = `${newWidth}px`
}
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[template.id]) {
columnWidths[template.id] = newWidth
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[template.id]) {
columnWidths[template.id] = newWidth
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
/>
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
/>
}
</div>)
})}
</div>
@ -276,6 +286,7 @@ class TableComponent extends React.Component<Props, State> {
}
}}
showCard={this.showCard}
readonly={this.props.readonly}
/>)
this.cardIdToRowMap.set(card.id, tableRowRef)
@ -286,17 +297,19 @@ class TableComponent extends React.Component<Props, State> {
{/* Add New row */}
<div className='octo-table-footer'>
<div
className='octo-table-cell'
onClick={() => {
this.addCard()
}}
>
<FormattedMessage
id='TableComponent.plus-new'
defaultMessage='+ New'
/>
</div>
{!this.props.readonly &&
<div
className='octo-table-cell'
onClick={() => {
this.addCard()
}}
>
<FormattedMessage
id='TableComponent.plus-new'
defaultMessage='+ New'
/>
</div>
}
</div>
</div>
</div>

View File

@ -19,6 +19,7 @@ type Props = {
focusOnMount: boolean
onSaveWithEnter: () => void
showCard: (cardId: string) => void
readonly: boolean
}
type State = {
@ -74,6 +75,7 @@ class TableRow extends React.Component<Props, State> {
}
}}
onCancel={() => this.setState({title: card.title})}
readonly={this.props.readonly}
/>
</div>
@ -99,7 +101,7 @@ class TableRow extends React.Component<Props, State> {
style={{width: this.columnWidth(template.id)}}
>
<PropertyValueElement
readOnly={false}
readOnly={this.props.readonly}
card={card}
boardTree={boardTree}
propertyTemplate={template}

View File

@ -15,11 +15,8 @@ import {CsvExporter} from '../csvExporter'
import mutator from '../mutator'
import {BoardTree} from '../viewModel/boardTree'
import Button from '../widgets/buttons/button'
import ButtonWithMenu from '../widgets/buttons/buttonWithMenu'
import IconButton from '../widgets/buttons/iconButton'
import CardIcon from '../widgets/icons/card'
import CheckIcon from '../widgets/icons/check'
import DeleteIcon from '../widgets/icons/delete'
import DropdownIcon from '../widgets/icons/dropdown'
import OptionsIcon from '../widgets/icons/options'
import SortDownIcon from '../widgets/icons/sortDown'
@ -29,6 +26,7 @@ import MenuWrapper from '../widgets/menuWrapper'
import {Editable} from './editable'
import FilterComponent from './filterComponent'
import NewCardButton from './newCardButton'
import './viewHeader.scss'
type Props = {
@ -41,6 +39,7 @@ type Props = {
editCardTemplate: (cardTemplateId: string) => void
withGroupBy?: boolean
intl: IntlShape
readonly: boolean
}
type State = {
@ -66,6 +65,282 @@ class ViewHeader extends React.Component<Props, State> {
}
}
render(): JSX.Element {
const {boardTree, showView, withGroupBy, intl} = this.props
const {board, activeView} = boardTree
const hasFilter = activeView.filter && activeView.filter.filters?.length > 0
const hasSort = activeView.sortOptions.length > 0
return (
<div className='ViewHeader'>
<Editable
style={{color: 'rgb(var(--main-fg))', fontWeight: 600}}
text={activeView.title}
placeholderText='Untitled View'
onChanged={(text) => {
mutator.changeTitle(activeView, text)
}}
readonly={this.props.readonly}
/>
<MenuWrapper>
<IconButton icon={<DropdownIcon/>}/>
<ViewMenu
board={board}
boardTree={boardTree}
showView={showView}
readonly={this.props.readonly}
/>
</MenuWrapper>
<div className='octo-spacer'/>
{!this.props.readonly &&
<>
{/* Card properties */}
<MenuWrapper>
<Button>
<FormattedMessage
id='ViewHeader.properties'
defaultMessage='Properties'
/>
</Button>
<Menu>
{boardTree.board.cardProperties.map((option: IPropertyTemplate) => (
<Menu.Switch
key={option.id}
id={option.id}
name={option.name}
isOn={activeView.visiblePropertyIds.includes(option.id)}
onClick={(propertyId: string) => {
let newVisiblePropertyIds = []
if (activeView.visiblePropertyIds.includes(propertyId)) {
newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o: string) => o !== propertyId)
} else {
newVisiblePropertyIds = [...activeView.visiblePropertyIds, propertyId]
}
mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
}}
/>
))}
</Menu>
</MenuWrapper>
{/* Group by */}
{withGroupBy &&
<MenuWrapper>
<Button>
<FormattedMessage
id='ViewHeader.group-by'
defaultMessage='Group by {property}'
values={{
property: (
<span
style={{color: 'rgb(var(--main-fg))'}}
id='groupByLabel'
>
{boardTree.groupByProperty?.name}
</span>
),
}}
/>
</Button>
<Menu>
{boardTree.board.cardProperties.filter((o: IPropertyTemplate) => o.type === 'select').map((option: IPropertyTemplate) => (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={boardTree.activeView.groupById === option.id ? <CheckIcon/> : undefined}
onClick={(id) => {
if (boardTree.activeView.groupById === id) {
return
}
mutator.changeViewGroupById(boardTree.activeView, id)
}}
/>
))}
</Menu>
</MenuWrapper>}
{/* Filter */}
<div className='filter-container'>
<Button
active={hasFilter}
onClick={this.showFilterDialog}
>
<FormattedMessage
id='ViewHeader.filter'
defaultMessage='Filter'
/>
</Button>
{this.state.showFilter &&
<FilterComponent
boardTree={boardTree}
onClose={this.hideFilterDialog}
/>}
</div>
{/* Sort */}
<MenuWrapper>
<Button active={hasSort}>
<FormattedMessage
id='ViewHeader.sort'
defaultMessage='Sort'
/>
</Button>
<Menu>
{(activeView.sortOptions.length > 0) &&
<>
<Menu.Text
id='manual'
name='Manual'
onClick={() => {
// This sets the manual card order to the currently displayed order
// Note: Perform this as a single update to change both properties correctly
const newView = new MutableBoardView(activeView)
newView.cardOrder = boardTree.orderedCards().map((o) => o.id)
newView.sortOptions = []
mutator.updateBlock(newView, activeView, 'reorder')
}}
/>
<Menu.Text
id='revert'
name='Revert'
onClick={() => {
mutator.changeViewSortOptions(activeView, [])
}}
/>
<Menu.Separator/>
</>
}
{this.sortDisplayOptions().map((option) => {
let rightIcon: JSX.Element | undefined
if (activeView.sortOptions.length > 0) {
const sortOption = activeView.sortOptions[0]
if (sortOption.propertyId === option.id) {
rightIcon = sortOption.reversed ? <SortUpIcon/> : <SortDownIcon/>
}
}
return (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={rightIcon}
onClick={(propertyId: string) => {
let newSortOptions: ISortOption[] = []
if (activeView.sortOptions[0] && activeView.sortOptions[0].propertyId === propertyId) {
// Already sorting by name, so reverse it
newSortOptions = [
{propertyId, reversed: !activeView.sortOptions[0].reversed},
]
} else {
newSortOptions = [
{propertyId, reversed: false},
]
}
mutator.changeViewSortOptions(activeView, newSortOptions)
}}
/>
)
})}
</Menu>
</MenuWrapper>
</>
}
{/* Search */}
{this.state.isSearching &&
<Editable
ref={this.searchFieldRef}
text={boardTree.getSearchText()}
placeholderText={intl.formatMessage({id: 'ViewHeader.search-text', defaultMessage: 'Search text'})}
style={{color: 'rgb(var(--main-fg))'}}
onChanged={(text) => {
this.searchChanged(text)
}}
onKeyDown={(e) => {
this.onSearchKeyDown(e)
}}
/>
}
{!this.state.isSearching &&
<Button onClick={() => this.setState({isSearching: true})}>
<FormattedMessage
id='ViewHeader.search'
defaultMessage='Search'
/>
</Button>}
{/* Options menu */}
{!this.props.readonly &&
<>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='exportCsv'
name={intl.formatMessage({id: 'ViewHeader.export-csv', defaultMessage: 'Export to CSV'})}
onClick={() => CsvExporter.exportTableCsv(boardTree)}
/>
<Menu.Text
id='exportBoardArchive'
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export board archive'})}
onClick={() => Archiver.exportBoardTree(boardTree)}
/>
<Menu.Separator/>
<Menu.Text
id='testAdd100Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-100-cards', defaultMessage: 'TEST: Add 100 cards'})}
onClick={() => this.testAddCards(100)}
/>
<Menu.Text
id='testAdd1000Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-1000-cards', defaultMessage: 'TEST: Add 1,000 cards'})}
onClick={() => this.testAddCards(1000)}
/>
<Menu.Text
id='testDistributeCards'
name={intl.formatMessage({id: 'ViewHeader.test-distribute-cards', defaultMessage: 'TEST: Distribute cards'})}
onClick={() => this.testDistributeCards()}
/>
<Menu.Text
id='testRandomizeIcons'
name={intl.formatMessage({id: 'ViewHeader.test-randomize-icons', defaultMessage: 'TEST: Randomize icons'})}
onClick={() => this.testRandomizeIcons()}
/>
</Menu>
</MenuWrapper>
{/* New card button */}
<NewCardButton
boardTree={this.props.boardTree}
addCard={this.props.addCard}
addCardFromTemplate={this.props.addCardFromTemplate}
addCardTemplate={this.props.addCardTemplate}
editCardTemplate={this.props.editCardTemplate}
/>
</>
}
</div>
)
}
private showFilterDialog = () => {
this.setState({showFilter: true})
}
@ -145,320 +420,6 @@ class ViewHeader extends React.Component<Props, State> {
})
}
render(): JSX.Element {
const {boardTree, showView, withGroupBy, intl} = this.props
const {board, activeView} = boardTree
const hasFilter = activeView.filter && activeView.filter.filters?.length > 0
const hasSort = activeView.sortOptions.length > 0
return (
<div className='ViewHeader'>
<Editable
style={{color: 'rgb(var(--main-fg))', fontWeight: 600}}
text={activeView.title}
placeholderText='Untitled View'
onChanged={(text) => {
mutator.changeTitle(activeView, text)
}}
/>
<MenuWrapper>
<IconButton icon={<DropdownIcon/>}/>
<ViewMenu
board={board}
boardTree={boardTree}
showView={showView}
/>
</MenuWrapper>
<div className='octo-spacer'/>
<MenuWrapper>
<Button>
<FormattedMessage
id='ViewHeader.properties'
defaultMessage='Properties'
/>
</Button>
<Menu>
{boardTree.board.cardProperties.map((option: IPropertyTemplate) => (
<Menu.Switch
key={option.id}
id={option.id}
name={option.name}
isOn={activeView.visiblePropertyIds.includes(option.id)}
onClick={(propertyId: string) => {
let newVisiblePropertyIds = []
if (activeView.visiblePropertyIds.includes(propertyId)) {
newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o: string) => o !== propertyId)
} else {
newVisiblePropertyIds = [...activeView.visiblePropertyIds, propertyId]
}
mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
}}
/>
))}
</Menu>
</MenuWrapper>
{withGroupBy &&
<MenuWrapper>
<Button>
<FormattedMessage
id='ViewHeader.group-by'
defaultMessage='Group by {property}'
values={{
property: (
<span
style={{color: 'rgb(var(--main-fg))'}}
id='groupByLabel'
>
{boardTree.groupByProperty?.name}
</span>
),
}}
/>
</Button>
<Menu>
{boardTree.board.cardProperties.filter((o: IPropertyTemplate) => o.type === 'select').map((option: IPropertyTemplate) => (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={boardTree.activeView.groupById === option.id ? <CheckIcon/> : undefined}
onClick={(id) => {
if (boardTree.activeView.groupById === id) {
return
}
mutator.changeViewGroupById(boardTree.activeView, id)
}}
/>
))}
</Menu>
</MenuWrapper>}
<div className='filter-container'>
<Button
active={hasFilter}
onClick={this.showFilterDialog}
>
<FormattedMessage
id='ViewHeader.filter'
defaultMessage='Filter'
/>
</Button>
{this.state.showFilter &&
<FilterComponent
boardTree={boardTree}
onClose={this.hideFilterDialog}
/>}
</div>
<MenuWrapper>
<Button active={hasSort}>
<FormattedMessage
id='ViewHeader.sort'
defaultMessage='Sort'
/>
</Button>
<Menu>
{(activeView.sortOptions.length > 0) &&
<>
<Menu.Text
id='manual'
name='Manual'
onClick={() => {
// This sets the manual card order to the currently displayed order
// Note: Perform this as a single update to change both properties correctly
const newView = new MutableBoardView(activeView)
newView.cardOrder = boardTree.orderedCards().map((o) => o.id)
newView.sortOptions = []
mutator.updateBlock(newView, activeView, 'reorder')
}}
/>
<Menu.Text
id='revert'
name='Revert'
onClick={() => {
mutator.changeViewSortOptions(activeView, [])
}}
/>
<Menu.Separator/>
</>
}
{this.sortDisplayOptions().map((option) => {
let rightIcon: JSX.Element | undefined
if (activeView.sortOptions.length > 0) {
const sortOption = activeView.sortOptions[0]
if (sortOption.propertyId === option.id) {
rightIcon = sortOption.reversed ? <SortUpIcon/> : <SortDownIcon/>
}
}
return (
<Menu.Text
key={option.id}
id={option.id}
name={option.name}
rightIcon={rightIcon}
onClick={(propertyId: string) => {
let newSortOptions: ISortOption[] = []
if (activeView.sortOptions[0] && activeView.sortOptions[0].propertyId === propertyId) {
// Already sorting by name, so reverse it
newSortOptions = [
{propertyId, reversed: !activeView.sortOptions[0].reversed},
]
} else {
newSortOptions = [
{propertyId, reversed: false},
]
}
mutator.changeViewSortOptions(activeView, newSortOptions)
}}
/>
)
})}
</Menu>
</MenuWrapper>
{this.state.isSearching &&
<Editable
ref={this.searchFieldRef}
text={boardTree.getSearchText()}
placeholderText={intl.formatMessage({id: 'ViewHeader.search-text', defaultMessage: 'Search text'})}
style={{color: 'rgb(var(--main-fg))'}}
onChanged={(text) => {
this.searchChanged(text)
}}
onKeyDown={(e) => {
this.onSearchKeyDown(e)
}}
/>}
{!this.state.isSearching &&
<Button onClick={() => this.setState({isSearching: true})}>
<FormattedMessage
id='ViewHeader.search'
defaultMessage='Search'
/>
</Button>}
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='exportCsv'
name={intl.formatMessage({id: 'ViewHeader.export-csv', defaultMessage: 'Export to CSV'})}
onClick={() => CsvExporter.exportTableCsv(boardTree)}
/>
<Menu.Text
id='exportBoardArchive'
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export board archive'})}
onClick={() => Archiver.exportBoardTree(boardTree)}
/>
<Menu.Separator/>
<Menu.Text
id='testAdd100Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-100-cards', defaultMessage: 'TEST: Add 100 cards'})}
onClick={() => this.testAddCards(100)}
/>
<Menu.Text
id='testAdd1000Cards'
name={intl.formatMessage({id: 'ViewHeader.test-add-1000-cards', defaultMessage: 'TEST: Add 1,000 cards'})}
onClick={() => this.testAddCards(1000)}
/>
<Menu.Text
id='testDistributeCards'
name={intl.formatMessage({id: 'ViewHeader.test-distribute-cards', defaultMessage: 'TEST: Distribute cards'})}
onClick={() => this.testDistributeCards()}
/>
<Menu.Text
id='testRandomizeIcons'
name={intl.formatMessage({id: 'ViewHeader.test-randomize-icons', defaultMessage: 'TEST: Randomize icons'})}
onClick={() => this.testRandomizeIcons()}
/>
</Menu>
</MenuWrapper>
<ButtonWithMenu
onClick={() => {
this.props.addCard()
}}
text={(
<FormattedMessage
id='ViewHeader.new'
defaultMessage='New'
/>
)}
>
<Menu position='left'>
{boardTree.cardTemplates.length > 0 && <>
<Menu.Label>
<b>
<FormattedMessage
id='ViewHeader.select-a-template'
defaultMessage='Select a template'
/>
</b>
</Menu.Label>
<Menu.Separator/>
</>}
{boardTree.cardTemplates.map((cardTemplate) => {
const displayName = cardTemplate.title || intl.formatMessage({id: 'ViewHeader.untitled', defaultMessage: 'Untitled'})
return (
<Menu.Text
key={cardTemplate.id}
id={cardTemplate.id}
name={displayName}
icon={<div className='Icon'>{cardTemplate.icon}</div>}
onClick={() => {
this.props.addCardFromTemplate(cardTemplate.id)
}}
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.id)
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'ViewHeader.delete-template', defaultMessage: 'Delete'})}
onClick={async () => {
await mutator.deleteBlock(cardTemplate, 'delete card template')
}}
/>
</Menu>
</MenuWrapper>
}
/>
)
})}
<Menu.Text
id='empty-template'
name={intl.formatMessage({id: 'ViewHeader.empty-card', defaultMessage: 'Empty card'})}
icon={<CardIcon/>}
onClick={() => {
this.props.addCard()
}}
/>
<Menu.Text
id='add-template'
name={intl.formatMessage({id: 'ViewHeader.add-template', defaultMessage: '+ New template'})}
onClick={() => this.props.addCardTemplate()}
/>
</Menu>
</ButtonWithMenu>
</div>
)
}
private sortDisplayOptions() {
const {boardTree} = this.props

View File

@ -20,6 +20,7 @@ type Props = {
board: Board,
showView: (id: string) => void
intl: IntlShape
readonly: boolean
}
export class ViewMenu extends React.PureComponent<Props> {
@ -112,31 +113,34 @@ export class ViewMenu extends React.PureComponent<Props> {
onClick={this.handleViewClick}
/>))}
<Menu.Separator/>
{boardTree.views.length > 1 &&
{!this.props.readonly && boardTree.views.length > 1 &&
<Menu.Text
id='__deleteView'
name='Delete View'
icon={<DeleteIcon/>}
onClick={this.handleDeleteView}
/>}
<Menu.SubMenu
id='__addView'
name='Add View'
icon={<AddIcon/>}
>
<Menu.Text
id='board'
name='Board'
icon={<BoardIcon/>}
onClick={this.handleAddViewBoard}
/>
<Menu.Text
id='table'
name='Table'
icon={<TableIcon/>}
onClick={this.handleAddViewTable}
/>
</Menu.SubMenu>
}
{!this.props.readonly &&
<Menu.SubMenu
id='__addView'
name='Add View'
icon={<AddIcon/>}
>
<Menu.Text
id='board'
name='Board'
icon={<BoardIcon/>}
onClick={this.handleAddViewBoard}
/>
<Menu.Text
id='table'
name='Table'
icon={<TableIcon/>}
onClick={this.handleAddViewTable}
/>
</Menu.SubMenu>
}
</Menu>
)
}

View File

@ -19,6 +19,7 @@ import './viewTitle.scss'
type Props = {
board: Board
intl: IntlShape
readonly: boolean
}
type State = {
@ -42,7 +43,7 @@ class ViewTitle extends React.Component<Props, State> {
return (
<>
<div className='ViewTitle add-buttons add-visible'>
{!board.icon &&
{!this.props.readonly && !board.icon &&
<Button
onClick={() => {
const newIcon = BlockIcons.shared.randomIcon()
@ -56,7 +57,7 @@ class ViewTitle extends React.Component<Props, State> {
/>
</Button>
}
{board.showDescription &&
{!this.props.readonly && board.showDescription &&
<Button
onClick={() => {
mutator.showDescription(board, false)
@ -69,7 +70,7 @@ class ViewTitle extends React.Component<Props, State> {
/>
</Button>
}
{!board.showDescription &&
{!this.props.readonly && !board.showDescription &&
<Button
onClick={() => {
mutator.showDescription(board, true)
@ -95,6 +96,7 @@ class ViewTitle extends React.Component<Props, State> {
saveOnEsc={true}
onSave={() => mutator.changeTitle(board, this.state.title)}
onCancel={() => this.setState({title: this.props.board.title})}
readonly={this.props.readonly}
/>
</div>
@ -106,6 +108,7 @@ class ViewTitle extends React.Component<Props, State> {
onBlur={(text) => {
mutator.changeDescription(board, text)
}}
readonly={this.props.readonly}
/>
</div>
}

View File

@ -19,6 +19,7 @@ type Props = {
showView: (id: string, boardId?: string) => void
setSearchText: (text?: string) => void
setLanguage: (lang: string) => void
readonly: boolean
}
class WorkspaceComponent extends React.PureComponent<Props> {
@ -28,13 +29,15 @@ class WorkspaceComponent extends React.PureComponent<Props> {
Utils.assert(workspaceTree)
const element = (
<div className='WorkspaceComponent'>
<Sidebar
showBoard={showBoard}
showView={showView}
workspaceTree={workspaceTree}
activeBoardId={boardTree?.board.id}
setLanguage={setLanguage}
/>
{!this.props.readonly &&
<Sidebar
showBoard={showBoard}
showView={showView}
workspaceTree={workspaceTree}
activeBoardId={boardTree?.board.id}
setLanguage={setLanguage}
/>
}
<div className='mainFrame'>
{(boardTree?.board.isTemplate) &&
<div className='banner'>
@ -66,6 +69,7 @@ class WorkspaceComponent extends React.PureComponent<Props> {
boardTree={boardTree}
setSearchText={setSearchText}
showView={showView}
readonly={this.props.readonly}
/>)
}
@ -75,6 +79,7 @@ class WorkspaceComponent extends React.PureComponent<Props> {
boardTree={boardTree}
setSearchText={setSearchText}
showView={showView}
readonly={this.props.readonly}
/>)
}

View File

@ -20,6 +20,7 @@ type State = {
viewId: string
workspaceTree: WorkspaceTree
boardTree?: BoardTree
readonly: boolean
}
export default class BoardPage extends React.Component<Props, State> {
@ -30,11 +31,13 @@ export default class BoardPage extends React.Component<Props, State> {
const queryString = new URLSearchParams(window.location.search)
const boardId = queryString.get('id') || ''
const viewId = queryString.get('v') || ''
const readonly = (queryString.get('r') === '1')
this.state = {
boardId,
viewId,
workspaceTree: new MutableWorkspaceTree(),
readonly,
}
Utils.log(`BoardPage. boardId: ${boardId}`)
@ -73,6 +76,10 @@ export default class BoardPage extends React.Component<Props, State> {
return
}
if (this.state.readonly) {
return
}
if (e.keyCode === 90 && !e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Cmd+Z
Utils.log('Undo')
if (mutator.canUndo) {
@ -136,6 +143,7 @@ export default class BoardPage extends React.Component<Props, State> {
this.setSearchText(text)
}}
setLanguage={this.props.setLanguage}
readonly={this.state.readonly}
/>
</div>
)
@ -246,6 +254,10 @@ export default class BoardPage extends React.Component<Props, State> {
let newUrl = window.location.protocol + '//' + window.location.host + window.location.pathname
if (boardId) {
newUrl += `?id=${encodeURIComponent(boardId)}`
if (this.state.readonly) {
newUrl += '&r=1'
}
}
window.history.pushState({path: newUrl}, '', newUrl)
@ -260,7 +272,10 @@ export default class BoardPage extends React.Component<Props, State> {
this.attachToBoard(boardId, viewId)
}
const newUrl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?id=${encodeURIComponent(boardId)}&v=${encodeURIComponent(viewId)}`
let newUrl = window.location.protocol + '//' + window.location.host + window.location.pathname + `?id=${encodeURIComponent(boardId)}&v=${encodeURIComponent(viewId)}`
if (this.state.readonly) {
newUrl += '&r=1'
}
window.history.pushState({path: newUrl}, '', newUrl)
}

View File

@ -10,6 +10,7 @@ type Props = {
placeholderText?: string
className?: string
saveOnEsc?: boolean
readonly?: boolean
onCancel?: () => void
onSave?: (saveType: 'onEnter'|'onEsc'|'onBlur') => void
@ -65,6 +66,7 @@ export default class Editable extends React.Component<Props> {
this.blur()
}
}}
readOnly={this.props.readonly}
/>
)
}

View File

@ -1,4 +1,8 @@
.MenuWrapper {
position: relative;
cursor: pointer;
&.disabled {
cursor: default;
}
}

View File

@ -11,6 +11,7 @@ type Props = {
isDisabled?: boolean;
stopPropagationOnToggle?: boolean;
className?: string
disabled?: boolean
}
type State = {
@ -68,6 +69,10 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
}
private toggle = (e: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
if (this.props.disabled) {
return
}
/**
* This is only here so that we can toggle the menus in the sidebar, because the default behavior of the mobile
* version (ie the one that uses a modal) needs propagation to close the modal after selecting something
@ -84,15 +89,22 @@ export default class MenuWrapper extends React.PureComponent<Props, State> {
public render(): JSX.Element {
const {children} = this.props
let className = 'MenuWrapper'
if (this.props.disabled) {
className += ' disabled'
}
if (this.props.className) {
className += ' ' + this.props.className
}
return (
<div
className={`MenuWrapper ${this.props.className || ''}`}
className={className}
onClick={this.toggle}
ref={this.node}
>
{children ? Object.values(children)[0] : null}
{children && this.state.open ? Object.values(children)[1] : null}
{children && !this.props.disabled && this.state.open ? Object.values(children)[1] : null}
</div>
)
}