1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-12-03 08:45:40 +02:00

Merge branch 'main' into auth

This commit is contained in:
Jesús Espino 2020-12-02 15:53:38 +01:00
commit dc5fb0cfc1
36 changed files with 693 additions and 298 deletions

View File

@ -2,6 +2,7 @@ package sqlstore
import (
"errors"
"os"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database"
@ -46,7 +47,7 @@ func (s *SQLStore) Migrate() error {
}
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
if err != nil && !errors.Is(err, migrate.ErrNoChange) && !errors.Is(err, os.ErrNotExist) {
return err
}

View File

@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0'>
<title><%= htmlWebpackPlugin.options.title %></title>
<link rel="stylesheet" href="/static/easymde.min.css">

View File

@ -25,20 +25,32 @@
"Filter.not-includes": "doesn't include",
"FilterComponent.add-filter": "+ Add Filter",
"FilterComponent.delete": "Delete",
"Mutator.duplicate-board": "duplicate board",
"Mutator.new-board-from-template": "new board from template",
"Mutator.new-card-from-template": "new card from template",
"Mutator.new-template-from-board": "new template from board",
"Mutator.new-template-from-card": "new template from card",
"Sidebar.add-board": "+ Add Board",
"Sidebar.add-template": "+ New template",
"Sidebar.dark-theme": "Dark Theme",
"Sidebar.delete-board": "Delete Board",
"Sidebar.delete-template": "Delete",
"Sidebar.duplicate-board": "Duplicate Board",
"Sidebar.edit-template": "Edit",
"Sidebar.empty-board": "Empty board",
"Sidebar.english": "English",
"Sidebar.export-archive": "Export Archive",
"Sidebar.import-archive": "Import Archive",
"Sidebar.light-theme": "Light Theme",
"Sidebar.mattermost-theme": "Mattermost Theme",
"Sidebar.no-views-in-board": "No pages inside",
"Sidebar.select-a-template": "Select a template",
"Sidebar.set-language": "Set Language",
"Sidebar.set-theme": "Set Theme",
"Sidebar.settings": "Settings",
"Sidebar.spanish": "Spanish",
"Sidebar.template-from-board": "New template from board",
"Sidebar.untitled": "Untitled",
"Sidebar.untitled-board": "(Untitled Board)",
"Sidebar.untitled-view": "(Untitled View)",
"TableComponent.add-icon": "Add Icon",
@ -73,8 +85,11 @@
"ViewHeader.test-distribute-cards": "TEST: Distribute cards",
"ViewHeader.test-randomize-icons": "TEST: Randomize icons",
"ViewHeader.untitled": "Untitled",
"ViewTitle.hide-description": "hide description",
"ViewTitle.pick-icon": "Pick Icon",
"ViewTitle.random-icon": "Random",
"ViewTitle.remove-icon": "Remove Icon",
"ViewTitle.untitled-board": "Untitled Board"
"ViewTitle.show-description": "show description",
"ViewTitle.untitled-board": "Untitled Board",
"WorkspaceComponent.editing-board-template": "You're editing a board template"
}

View File

@ -26,7 +26,9 @@
"jest": {
"transform": {
"^.+\\.tsx?$": "ts-jest"
}
},
"collectCoverage": true,
"collectCoverageFrom": ["src/**/*.{ts,tsx,js,jsx}"]
},
"devDependencies": {
"@formatjs/cli": "^2.13.2",

View File

@ -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,11 @@ interface IMutablePropertyTemplate extends IPropertyTemplate {
interface Board extends IBlock {
readonly icon: string
readonly description: string
readonly showDescription: boolean
readonly isTemplate: boolean
readonly cardProperties: readonly IPropertyTemplate[]
duplicate(): MutableBoard
}
class MutableBoard extends MutableBlock {
@ -46,6 +52,27 @@ class MutableBoard extends MutableBlock {
this.fields.icon = value
}
get description(): string {
return this.fields.description as string
}
set description(value: string) {
this.fields.description = value
}
get showDescription(): boolean {
return Boolean(this.fields.showDescription)
}
set showDescription(value: boolean) {
this.fields.showDescription = 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[]
}
@ -58,6 +85,7 @@ class MutableBoard extends MutableBlock {
this.type = 'board'
this.icon = block.fields?.icon || ''
this.description = block.fields?.description || ''
if (block.fields?.cardProperties) {
// Deep clone of card properties and their options
this.cardProperties = block.fields.cardProperties.map((o: IPropertyTemplate) => {
@ -72,6 +100,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}

View File

@ -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

View File

@ -5,7 +5,6 @@ import React from 'react'
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {BlockIcons} from '../blockIcons'
import {IBlock} from '../blocks/block'
import {IPropertyOption, IPropertyTemplate} from '../blocks/board'
import {Card, MutableCard} from '../blocks/card'
import {CardFilter} from '../cardFilter'
@ -13,7 +12,6 @@ import {Constants} from '../constants'
import mutator from '../mutator'
import {Utils} from '../utils'
import {BoardTree, BoardTreeGroup} from '../viewModel/boardTree'
import {MutableCardTree} from '../viewModel/cardTree'
import Button from '../widgets/buttons/button'
import IconButton from '../widgets/buttons/iconButton'
import AddIcon from '../widgets/icons/add'
@ -27,7 +25,7 @@ import MenuWrapper from '../widgets/menuWrapper'
import BoardCard from './boardCard'
import {BoardColumn} from './boardColumn'
import './boardComponent.scss'
import {CardDialog} from './cardDialog'
import CardDialog from './cardDialog'
import {Editable} from './editable'
import RootPortal from './rootPortal'
import ViewHeader from './viewHeader'
@ -279,19 +277,19 @@ class BoardComponent extends React.Component<Props, State> {
}}
onDragOver={(e) => {
ref.current!.classList.add('dragover')
ref.current?.classList.add('dragover')
e.preventDefault()
}}
onDragEnter={(e) => {
ref.current!.classList.add('dragover')
ref.current?.classList.add('dragover')
e.preventDefault()
}}
onDragLeave={(e) => {
ref.current!.classList.remove('dragover')
ref.current?.classList.remove('dragover')
e.preventDefault()
}}
onDrop={(e) => {
ref.current!.classList.remove('dragover')
ref.current?.classList.remove('dragover')
e.preventDefault()
this.onDropToColumn(group.option)
}}
@ -348,19 +346,19 @@ class BoardComponent extends React.Component<Props, State> {
}}
onDragOver={(e) => {
ref.current!.classList.add('dragover')
ref.current?.classList.add('dragover')
e.preventDefault()
}}
onDragEnter={(e) => {
ref.current!.classList.add('dragover')
ref.current?.classList.add('dragover')
e.preventDefault()
}}
onDragLeave={(e) => {
ref.current!.classList.remove('dragover')
ref.current?.classList.remove('dragover')
e.preventDefault()
}}
onDrop={(e) => {
ref.current!.classList.remove('dragover')
ref.current?.classList.remove('dragover')
e.preventDefault()
this.onDropToColumn(group.option)
}}
@ -424,25 +422,25 @@ class BoardComponent extends React.Component<Props, State> {
if (this.draggedCards?.length < 1) {
return
}
ref.current!.classList.add('dragover')
ref.current?.classList.add('dragover')
e.preventDefault()
}}
onDragEnter={(e) => {
if (this.draggedCards?.length < 1) {
return
}
ref.current!.classList.add('dragover')
ref.current?.classList.add('dragover')
e.preventDefault()
}}
onDragLeave={(e) => {
if (this.draggedCards?.length < 1) {
return
}
ref.current!.classList.remove('dragover')
ref.current?.classList.remove('dragover')
e.preventDefault()
}}
onDrop={(e) => {
ref.current!.classList.remove('dragover')
ref.current?.classList.remove('dragover')
e.preventDefault()
if (this.draggedCards?.length < 1) {
return
@ -478,28 +476,25 @@ class BoardComponent extends React.Component<Props, State> {
}
}
private addCardFromTemplate = async (cardTemplateId?: string) => {
this.addCard(undefined, cardTemplateId)
private addCardFromTemplate = async (cardTemplateId: string) => {
await mutator.duplicateCard(
cardTemplateId,
this.props.intl.formatMessage({id: 'Mutator.new-card-from-template', defaultMessage: 'new card from template'}),
false,
async (newCardId) => {
this.setState({shownCardId: newCardId})
},
async () => {
this.setState({shownCardId: undefined})
},
)
}
private async addCard(groupByOptionId?: string, cardTemplateId?: string): Promise<void> {
private async addCard(groupByOptionId?: string): Promise<void> {
const {boardTree} = this.props
const {activeView, board} = boardTree
let card: MutableCard
let blocksToInsert: IBlock[]
if (cardTemplateId) {
const templateCardTree = new MutableCardTree(cardTemplateId)
await templateCardTree.sync()
const newCardTree = templateCardTree.templateCopy()
card = newCardTree.card
card.isTemplate = false
card.title = ''
blocksToInsert = [newCardTree.card, ...newCardTree.contents]
} else {
card = new MutableCard()
blocksToInsert = [card]
}
const card = new MutableCard()
card.parentId = boardTree.board.id
const propertiesThatMeetFilters = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
@ -511,9 +506,11 @@ class BoardComponent extends React.Component<Props, State> {
}
}
card.properties = {...card.properties, ...propertiesThatMeetFilters}
if (!card.icon) {
card.icon = BlockIcons.shared.randomIcon()
await mutator.insertBlocks(
blocksToInsert,
}
await mutator.insertBlock(
card,
'add card',
async () => {
this.setState({shownCardId: card.id})

View File

@ -44,8 +44,10 @@ class CardDetail extends React.Component<Props, State> {
}
componentDidMount(): void {
if (!this.state.title) {
this.titleRef.current?.focus()
}
}
constructor(props: Props) {
super(props)

View File

@ -1,7 +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 {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import mutator from '../mutator'
import {OctoListener} from '../octoListener'
@ -19,6 +19,7 @@ type Props = {
cardId: string
onClose: () => void
showCard: (cardId?: string) => void
intl: IntlShape
}
type State = {
@ -94,7 +95,7 @@ class CardDialog extends React.Component<Props, State> {
<Menu.Text
id='makeTemplate'
name='New template from card'
onClick={this.makeTemplate}
onClick={this.makeTemplateClicked}
/>
}
</Menu>
@ -122,25 +123,19 @@ class CardDialog extends React.Component<Props, State> {
)
}
private makeTemplate = async () => {
private makeTemplateClicked = async () => {
const {cardTree} = this.state
if (!cardTree) {
Utils.assertFailure('this.state.cardTree')
return
}
const newCardTree = cardTree.templateCopy()
newCardTree.card.isTemplate = true
newCardTree.card.title = 'New Template'
Utils.log(`Created new template: ${newCardTree.card.id}`)
const blocksToInsert = [newCardTree.card, ...newCardTree.contents]
await mutator.insertBlocks(
blocksToInsert,
'create template from card',
async () => {
this.props.showCard(newCardTree.card.id)
await mutator.duplicateCard(
cardTree.card.id,
this.props.intl.formatMessage({id: 'Mutator.new-template-from-card', defaultMessage: 'new template from card'}),
true,
async (newCardId) => {
this.props.showCard(newCardId)
},
async () => {
this.props.showCard(undefined)
@ -149,4 +144,4 @@ class CardDialog extends React.Component<Props, State> {
}
}
export {CardDialog}
export default injectIntl(CardDialog)

View File

@ -17,13 +17,27 @@
background-color: rgb(var(--main-bg));
border-radius: 3px;
box-shadow: rgba(var(--main-fg), 0.1) 0px 0px 0px 1px, rgba(var(--main-fg), 0.1) 0px 2px 4px;
margin: 72px auto;
padding: 0;
max-width: 975px;
height: calc(100% - 144px);
overflow-x: hidden;
overflow-y: auto;
@media not screen and (max-width: 975px) {
margin: 72px auto;
max-width: 975px;
height: calc(100% - 144px);
}
@media screen and (max-width: 975px) {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
> * {
flex-shrink: 0;
}
> .banner {
background-color: rgba(230, 220, 192, 0.9);
text-align: center;
@ -33,13 +47,26 @@
display: flex;
flex-direction: row;
height: 30px;
margin: 10px
margin: 10px;
> .IconButton:first-child {
/* Hide close button on larger screens */
@media not screen and (max-width: 975px) {
display: none;
}
}
}
> .content {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 10px 126px 10px 126px;
@media not screen and (max-width: 975px) {
padding: 10px 126px;
}
@media screen and (max-width: 975px) {
padding: 10px 10px;
}
}
> .content.fullwidth {
padding: 10px 0 10px 0;

View File

@ -2,10 +2,10 @@
// See LICENSE.txt for license information.
import React from 'react'
import MenuWrapper from '../widgets/menuWrapper'
import OptionsIcon from '../widgets/icons/options'
import IconButton from '../widgets/buttons/iconButton'
import CloseIcon from '../widgets/icons/close'
import OptionsIcon from '../widgets/icons/options'
import MenuWrapper from '../widgets/menuWrapper'
import './dialog.scss'
type Props = {
@ -23,17 +23,13 @@ export default class Dialog extends React.PureComponent<Props> {
document.removeEventListener('keydown', this.keydownHandler)
}
private close(): void {
this.props.onClose()
}
private keydownHandler = (e: KeyboardEvent): void => {
if (e.target !== document.body) {
return
}
if (e.keyCode === 27) {
this.close()
this.closeClicked()
e.stopPropagation()
}
}
@ -46,13 +42,18 @@ export default class Dialog extends React.PureComponent<Props> {
className='Dialog dialog-back'
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
this.close()
this.closeClicked()
}
}}
>
<div className='dialog' >
{toolsMenu &&
<div className='toolbar'>
<IconButton
onClick={this.closeClicked}
icon={<CloseIcon/>}
title={'Close dialog'}
/>
<div className='octo-spacer'/>
<MenuWrapper>
<IconButton icon={<OptionsIcon/>}/>
@ -64,4 +65,8 @@ export default class Dialog extends React.PureComponent<Props> {
</div>
)
}
private closeClicked = () => {
this.props.onClose()
}
}

View File

@ -32,12 +32,17 @@ class Editable extends React.PureComponent<Props> {
return this.privateText
}
set text(value: string) {
if (!this.elementRef.current) {
Utils.assertFailure('elementRef.current')
return
}
const {isMarkdown} = this.props
if (value) {
this.elementRef.current!.innerHTML = isMarkdown ? Utils.htmlFromMarkdown(value) : Utils.htmlEncode(value)
this.elementRef.current.innerHTML = isMarkdown ? Utils.htmlFromMarkdown(value) : Utils.htmlEncode(value)
} else {
this.elementRef.current!.innerText = ''
this.elementRef.current.innerText = ''
}
this.privateText = value || ''
@ -55,7 +60,7 @@ class Editable extends React.PureComponent<Props> {
}
focus(): void {
this.elementRef.current!.focus()
this.elementRef.current?.focus()
// Put cursor at end
document.execCommand('selectAll', false, undefined)
@ -63,7 +68,7 @@ class Editable extends React.PureComponent<Props> {
}
blur(): void {
this.elementRef.current!.blur()
this.elementRef.current?.blur()
}
render(): JSX.Element {
@ -90,9 +95,11 @@ class Editable extends React.PureComponent<Props> {
dangerouslySetInnerHTML={{__html: html}}
onFocus={() => {
this.elementRef.current!.innerText = this.text
this.elementRef.current!.style!.color = style?.color || ''
this.elementRef.current!.classList.add('active')
if (this.elementRef.current) {
this.elementRef.current.innerText = this.text
this.elementRef.current.style.color = style?.color || ''
this.elementRef.current.classList.add('active')
}
if (onFocus) {
onFocus()
@ -100,7 +107,8 @@ class Editable extends React.PureComponent<Props> {
}}
onBlur={async () => {
const newText = this.elementRef.current!.innerText
if (this.elementRef.current) {
const newText = this.elementRef.current.innerText
const oldText = this.props.text || ''
if (this.props.allowEmpty || newText) {
if (newText !== oldText && onChanged) {
@ -112,7 +120,9 @@ class Editable extends React.PureComponent<Props> {
this.text = oldText // Reset text
}
this.elementRef.current!.classList.remove('active')
this.elementRef.current.classList.remove('active')
}
if (onBlur) {
onBlur()
}
@ -121,10 +131,10 @@ class Editable extends React.PureComponent<Props> {
onKeyDown={(e) => {
if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC
e.stopPropagation()
this.elementRef.current!.blur()
this.elementRef.current?.blur()
} else if (!isMultiline && e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return
e.stopPropagation()
this.elementRef.current!.blur()
this.elementRef.current?.blur()
}
if (onKeyDown) {

View File

@ -152,9 +152,8 @@ class MarkdownEditor extends React.Component<Props, State> {
this.hideEditor()
},
focus: () => {
this.frameRef.current!.classList.add('active')
this.elementRef.current!.setState({value: this.text})
this.frameRef.current?.classList.add('active')
this.elementRef.current?.setState({value: this.text})
if (onFocus) {
onFocus()

View File

@ -8,13 +8,12 @@
color: rgb(var(--sidebar-fg));
background-color: rgb(var(--sidebar-bg));
padding: 10px 0;
overflow-y: scroll;
&.hidden {
position: absolute;
top: 0;
left: 0;
z-index: 15;
z-index: 5;
min-height: 0;
height: 50px;
width: 50px;
@ -30,6 +29,12 @@
flex-shrink: 0;
}
.octo-sidebar-list {
overflow-y: auto;
max-height: calc(100% - 78px);
max-width: 250px;
}
.octo-sidebar-header {
display: flex;
flex-direction: row;
@ -37,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);
@ -82,7 +87,7 @@
}
}
.IconButton {
>.IconButton {
background-color: var(--sidebar-bg);
&:hover {
background-color: rgba(var(--sidebar-fg), 0.1);
@ -122,6 +127,10 @@
flex-shrink: 0;
}
.Menu .OptionsIcon {
fill: unset;
}
.HideSidebarIcon {
stroke: rgba(var(--sidebar-fg), 0.5);
stroke-width: 6px;

View File

@ -12,13 +12,13 @@ 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'
@ -87,6 +87,7 @@ class Sidebar extends React.Component<Props, State> {
icon={<HideSidebarIcon/>}
/>
</div>
<div className='octo-sidebar-list'>
{
boards.map((board) => {
const displayTitle: string = board.title || intl.formatMessage({id: 'Sidebar.untitled-board', defaultMessage: '(Untitled Board)'})
@ -137,17 +138,16 @@ class Sidebar extends React.Component<Props, State> {
id='duplicateBoard'
name={intl.formatMessage({id: 'Sidebar.duplicate-board', defaultMessage: 'Duplicate Board'})}
icon={<DuplicateIcon/>}
onClick={async () => {
await mutator.duplicateBoard(
board.id,
'duplicate board',
async (newBoardId) => {
newBoardId && this.props.showBoard(newBoardId)
},
async () => {
this.props.showBoard(board.id)
},
)
onClick={() => {
this.duplicateBoard(board.id)
}}
/>
<Menu.Text
id='templateFromBoard'
name={intl.formatMessage({id: 'Sidebar.template-from-board', defaultMessage: 'New template from board'})}
onClick={() => {
this.addTemplateFromBoard(board.id)
}}
/>
</Menu>
@ -184,14 +184,78 @@ class Sidebar extends React.Component<Props, State> {
<br/>
<Button
onClick={this.addBoardClicked}
>
<MenuWrapper>
<Button>
<FormattedMessage
id='Sidebar.add-board'
defaultMessage='+ Add Board'
/>
</Button>
<Menu position='top'>
<Menu.Label>
<b>
<FormattedMessage
id='Sidebar.select-a-template'
defaultMessage='Select a template'
/>
</b>
</Menu.Label>
<Menu.Separator/>
{workspaceTree.boardTemplates.map((boardTemplate) => {
let displayName = boardTemplate.title || intl.formatMessage({id: 'Sidebar.untitled', defaultMessage: 'Untitled'})
if (boardTemplate.icon) {
displayName = `${boardTemplate.icon} ${displayName}`
}
return (
<Menu.Text
key={boardTemplate.id}
id={boardTemplate.id}
name={displayName}
onClick={() => {
this.addBoardFromTemplate(boardTemplate.id)
}}
rightIcon={
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
<Menu position='left'>
<Menu.Text
id='edit'
name={intl.formatMessage({id: 'Sidebar.edit-template', defaultMessage: 'Edit'})}
onClick={() => {
this.props.showBoard(boardTemplate.id)
}}
/>
<Menu.Text
icon={<DeleteIcon/>}
id='delete'
name={intl.formatMessage({id: 'Sidebar.delete-template', defaultMessage: 'Delete'})}
onClick={async () => {
await mutator.deleteBlock(boardTemplate, 'delete board template')
}}
/>
</Menu>
</MenuWrapper>
}
/>
)
})}
<Menu.Text
id='empty-template'
name={intl.formatMessage({id: 'Sidebar.empty-board', defaultMessage: 'Empty board'})}
onClick={this.addBoardClicked}
/>
<Menu.Text
id='add-template'
name={intl.formatMessage({id: 'Sidebar.add-template', defaultMessage: '+ New template'})}
onClick={this.addBoardTemplateClicked}
/>
</Menu>
</MenuWrapper>
</div>
<div className='octo-spacer'/>
@ -268,7 +332,9 @@ class Sidebar extends React.Component<Props, State> {
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
@ -288,6 +354,78 @@ class Sidebar extends React.Component<Props, State> {
)
}
private async addBoardFromTemplate(boardTemplateId: string) {
const oldBoardId = this.props.activeBoardId
await mutator.duplicateBoard(
boardTemplateId,
this.props.intl.formatMessage({id: 'Mutator.new-board-from-template', defaultMessage: 'new board from template'}),
false,
async (newBoardId) => {
this.props.showBoard(newBoardId)
},
async () => {
if (oldBoardId) {
this.props.showBoard(oldBoardId)
}
},
)
}
private async duplicateBoard(boardId: string) {
const oldBoardId = this.props.activeBoardId
await mutator.duplicateBoard(
boardId,
this.props.intl.formatMessage({id: 'Mutator.duplicate-board', defaultMessage: 'duplicate board'}),
false,
async (newBoardId) => {
this.props.showBoard(newBoardId)
},
async () => {
if (oldBoardId) {
this.props.showBoard(oldBoardId)
}
},
)
}
private async addTemplateFromBoard(boardId: string) {
const oldBoardId = this.props.activeBoardId
await mutator.duplicateBoard(
boardId,
this.props.intl.formatMessage({id: 'Mutator.new-template-from-board', defaultMessage: 'new template from board'}),
true,
async (newBoardId) => {
this.props.showBoard(newBoardId)
},
async () => {
if (oldBoardId) {
this.props.showBoard(oldBoardId)
}
},
)
}
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})
}

View File

@ -1,10 +1,9 @@
// 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 {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {BlockIcons} from '../blockIcons'
import {IBlock} from '../blocks/block'
import {IPropertyTemplate} from '../blocks/board'
import {MutableBoardView} from '../blocks/boardView'
import {MutableCard} from '../blocks/card'
@ -12,12 +11,11 @@ import {Constants} from '../constants'
import mutator from '../mutator'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
import {MutableCardTree} from '../viewModel/cardTree'
import SortDownIcon from '../widgets/icons/sortDown'
import SortUpIcon from '../widgets/icons/sortUp'
import MenuWrapper from '../widgets/menuWrapper'
import {CardDialog} from './cardDialog'
import CardDialog from './cardDialog'
import {HorizontalGrip} from './horizontalGrip'
import RootPortal from './rootPortal'
import './tableComponent.scss'
@ -30,6 +28,7 @@ type Props = {
boardTree: BoardTree
showView: (id: string) => void
setSearchText: (text?: string) => void
intl: IntlShape
}
type State = {
@ -127,13 +126,17 @@ class TableComponent extends React.Component<Props, State> {
onDrag={(offset) => {
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
titleRef.current!.style!.width = `${newWidth}px`
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)
titleRef.current!.style!.width = `${newWidth}px`
if (titleRef.current) {
titleRef.current.style.width = `${newWidth}px`
}
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[Constants.titleColumnId]) {
@ -211,13 +214,17 @@ class TableComponent extends React.Component<Props, State> {
onDrag={(offset) => {
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
headerRef.current!.style.width = `${newWidth}px`
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)
headerRef.current!.style.width = `${newWidth}px`
if (headerRef.current) {
headerRef.current.style.width = `${newWidth}px`
}
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[template.id]) {
@ -246,7 +253,7 @@ class TableComponent extends React.Component<Props, State> {
const tableRow = (
<TableRow
key={card.id}
key={card.id + card.updateAt}
ref={tableRowRef}
boardTree={boardTree}
card={card}
@ -296,32 +303,31 @@ class TableComponent extends React.Component<Props, State> {
this.addCard(true)
}
private addCardFromTemplate = async (cardTemplateId?: string) => {
this.addCard(true, cardTemplateId)
private addCardFromTemplate = async (cardTemplateId: string) => {
await mutator.duplicateCard(
cardTemplateId,
this.props.intl.formatMessage({id: 'Mutator.new-card-from-template', defaultMessage: 'new card from template'}),
false,
async (newCardId) => {
this.setState({shownCardId: newCardId})
},
async () => {
this.setState({shownCardId: undefined})
},
)
}
private addCard = async (show = false, cardTemplateId?: string) => {
private addCard = async (show = false) => {
const {boardTree} = this.props
let card: MutableCard
let blocksToInsert: IBlock[]
if (cardTemplateId) {
const templateCardTree = new MutableCardTree(cardTemplateId)
await templateCardTree.sync()
const newCardTree = templateCardTree.templateCopy()
card = newCardTree.card
card.isTemplate = false
card.title = ''
blocksToInsert = [newCardTree.card, ...newCardTree.contents]
} else {
card = new MutableCard()
blocksToInsert = [card]
}
const card = new MutableCard()
card.parentId = boardTree.board.id
if (!card.icon) {
card.icon = BlockIcons.shared.randomIcon()
await mutator.insertBlocks(
blocksToInsert,
}
await mutator.insertBlock(
card,
'add card',
async () => {
if (show) {
@ -375,4 +381,4 @@ class TableComponent extends React.Component<Props, State> {
}
}
export {TableComponent}
export default injectIntl(TableComponent)

View File

@ -40,7 +40,7 @@ class TableRow extends React.Component<Props, State> {
componentDidMount(): void {
if (this.props.focusOnMount) {
setTimeout(() => this.titleRef.current!.focus(), 10)
setTimeout(() => this.titleRef.current?.focus(), 10)
}
}
@ -51,7 +51,6 @@ class TableRow extends React.Component<Props, State> {
return (
<div
className='TableRow octo-table-row'
key={card.id}
>
{/* Name / title */}

View File

@ -35,7 +35,7 @@ type Props = {
showView: (id: string) => void
setSearchText: (text?: string) => void
addCard: () => void
addCardFromTemplate: (cardTemplateId?: string) => void
addCardFromTemplate: (cardTemplateId: string) => void
addCardTemplate: () => void
editCardTemplate: (cardTemplateId: string) => void
withGroupBy?: boolean
@ -61,7 +61,7 @@ class ViewHeader extends React.Component<Props, State> {
componentDidUpdate(prevPros: Props, prevState: State): void {
if (this.state.isSearching && !prevState.isSearching) {
this.searchFieldRef.current!.focus()
this.searchFieldRef.current?.focus()
}
}
@ -75,7 +75,9 @@ class ViewHeader extends React.Component<Props, State> {
private onSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.keyCode === 27) { // ESC: Clear search
this.searchFieldRef.current!.text = ''
if (this.searchFieldRef.current) {
this.searchFieldRef.current.text = ''
}
this.setState({isSearching: false})
this.props.setSearchText(undefined)
e.preventDefault()
@ -397,11 +399,15 @@ class ViewHeader extends React.Component<Props, State> {
<Menu.Separator/>
{boardTree.cardTemplates.map((cardTemplate) => {
let displayName = cardTemplate.title || intl.formatMessage({id: 'ViewHeader.untitled', defaultMessage: 'Untitled'})
if (cardTemplate.icon) {
displayName = `${cardTemplate.icon} ${displayName}`
}
return (
<Menu.Text
key={cardTemplate.id}
id={cardTemplate.id}
name={cardTemplate.title || intl.formatMessage({id: 'ViewHeader.untitled', defaultMessage: 'Untitled'})}
name={displayName}
onClick={() => {
this.props.addCardFromTemplate(cardTemplate.id)
}}

View File

@ -5,7 +5,7 @@
align-items: center;
&.add-buttons {
flex-direction: column;
flex-direction: row;
min-height: 30px;
color:rgba(var(--main-fg), 0.4);
width: 100%;
@ -28,4 +28,8 @@
margin-bottom: 0px;
flex-grow: 1;
}
&.description > * {
flex-grow: 1;
}
}

View File

@ -9,8 +9,11 @@ import mutator from '../mutator'
import Button from '../widgets/buttons/button'
import Editable from '../widgets/editable'
import EmojiIcon from '../widgets/icons/emoji'
import HideIcon from '../widgets/icons/hide'
import ShowIcon from '../widgets/icons/show'
import BlockIconSelector from './blockIconSelector'
import {MarkdownEditor} from './markdownEditor'
import './viewTitle.scss'
type Props = {
@ -38,7 +41,8 @@ class ViewTitle extends React.Component<Props, State> {
return (
<>
<div className={'ViewTitle add-buttons ' + (board.icon ? '' : 'add-visible')}>
<div className='ViewTitle add-buttons add-visible'>
{!board.icon &&
<Button
onClick={() => {
const newIcon = BlockIcons.shared.randomIcon()
@ -51,6 +55,33 @@ class ViewTitle extends React.Component<Props, State> {
defaultMessage='Add Icon'
/>
</Button>
}
{board.showDescription &&
<Button
onClick={() => {
mutator.showDescription(board, false)
}}
icon={<HideIcon/>}
>
<FormattedMessage
id='ViewTitle.hide-description'
defaultMessage='hide description'
/>
</Button>
}
{!board.showDescription &&
<Button
onClick={() => {
mutator.showDescription(board, true)
}}
icon={<ShowIcon/>}
>
<FormattedMessage
id='ViewTitle.show-description'
defaultMessage='show description'
/>
</Button>
}
</div>
<div className='ViewTitle'>
@ -66,6 +97,18 @@ class ViewTitle extends React.Component<Props, State> {
onCancel={() => this.setState({title: this.props.board.title})}
/>
</div>
{board.showDescription &&
<div className='ViewTitle description'>
<MarkdownEditor
text={board.description}
placeholderText='Add a description...'
onBlur={(text) => {
mutator.changeDescription(board, text)
}}
/>
</div>
}
</>
)
}

View File

@ -3,4 +3,17 @@
display: flex;
flex-direction: row;
overflow: auto;
> .mainFrame {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: auto;
> .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.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {Utils} from '../utils'
import {BoardTree} from '../viewModel/boardTree'
@ -8,7 +9,7 @@ import {WorkspaceTree} from '../viewModel/workspaceTree'
import BoardComponent from './boardComponent'
import Sidebar from './sidebar'
import {TableComponent} from './tableComponent'
import TableComponent from './tableComponent'
import './workspaceComponent.scss'
type Props = {
@ -34,7 +35,17 @@ class WorkspaceComponent extends React.PureComponent<Props> {
activeBoardId={boardTree?.board.id}
setLanguage={setLanguage}
/>
<div className='mainFrame'>
{(boardTree?.board.isTemplate) &&
<div className='banner'>
<FormattedMessage
id='WorkspaceComponent.editing-board-template'
defaultMessage="You're editing a board template"
/>
</div>
}
{this.mainComponent()}
</div>
</div>)
return element

View File

@ -156,6 +156,22 @@ class Mutator {
await this.updateBlock(newBlock, block, description)
}
async changeDescription(block: IBlock, boardDescription: string, description = 'change description') {
const newBoard = new MutableBoard(block)
newBoard.description = boardDescription
await this.updateBlock(newBoard, block, description)
}
async showDescription(board: Board, showDescription = true, description?: string) {
const newBoard = new MutableBoard(board)
newBoard.showDescription = showDescription
let actionDescription = description
if (!actionDescription) {
actionDescription = showDescription ? 'show description' : 'hide description'
}
await this.updateBlock(newBoard, board, actionDescription)
}
async changeOrder(block: IOrderedBlock, order: number, description = 'change order') {
const newBlock = new MutableOrderedBlock(block)
newBlock.order = order
@ -484,42 +500,69 @@ class Mutator {
// Duplicate
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',
asTemplate = false,
afterRedo?: (newCardId: string) => Promise<void>,
beforeUndo?: () => Promise<void>,
): Promise<[IBlock[], string]> {
const blocks = await octoClient.getSubtree(cardId, 2)
const [newBlocks1, idMap] = OctoUtils.duplicateBlockTree(blocks, cardId)
const [newBlocks1, newCard] = OctoUtils.duplicateBlockTree(blocks, cardId) as [IBlock[], MutableCard, Record<string, string>]
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)!
if (asTemplate === newCard.isTemplate) {
newCard.title = `Copy of ${newCard.title}`
} else if (asTemplate) {
// Template from card
newCard.title = 'New card template'
} else {
// Card from template
newCard.title = ''
}
newCard.isTemplate = asTemplate
await this.insertBlocks(
newBlocks,
description,
async () => {
await afterRedo?.(newCardId)
await afterRedo?.(newCard.id)
},
beforeUndo,
)
return [newBlocks, newCardId]
return [newBlocks, newCard.id]
}
async duplicateBoard(boardId: string, description = 'duplicate board', afterRedo?: (newBoardId: string) => Promise<void>, beforeUndo?: () => Promise<void>): Promise<[IBlock[], string]> {
async duplicateBoard(
boardId: string,
description = 'duplicate board',
asTemplate = false,
afterRedo?: (newBoardId: string) => Promise<void>,
beforeUndo?: () => Promise<void>,
): Promise<[IBlock[], string]> {
const blocks = await octoClient.getSubtree(boardId, 3)
const [newBlocks1, idMap] = OctoUtils.duplicateBlockTree(blocks, boardId)
const [newBlocks1, newBoard] = OctoUtils.duplicateBlockTree(blocks, boardId) as [IBlock[], MutableBoard, Record<string, string>]
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)!
if (asTemplate === newBoard.isTemplate) {
newBoard.title = `Copy of ${newBoard.title}`
} else if (asTemplate) {
// Template from board
newBoard.title = 'New board template'
} else {
// Board from template
newBoard.title = ''
}
newBoard.isTemplate = asTemplate
await this.insertBlocks(
newBlocks,
description,
async () => {
await afterRedo?.(newBoardId)
await afterRedo?.(newBoard.id)
},
beforeUndo,
)
return [newBlocks, newBoardId]
return [newBlocks, newBoard.id]
}
// Other methods

View File

@ -88,7 +88,7 @@ class OctoUtils {
}
// Creates a copy of the blocks with new ids and parentIDs
static duplicateBlockTree(blocks: IBlock[], rootBlockId?: string): [MutableBlock[], Readonly<Record<string, string>>] {
static duplicateBlockTree(blocks: IBlock[], rootBlockId: string): [MutableBlock[], MutableBlock, Readonly<Record<string, string>>] {
const idMap: Record<string, string> = {}
const newBlocks = blocks.map((block) => {
const newBlock = this.hydrateBlock(block)
@ -97,7 +97,7 @@ class OctoUtils {
return newBlock
})
const newRootBlockId = rootBlockId ? idMap[rootBlockId] : undefined
const newRootBlockId = idMap[rootBlockId]
newBlocks.forEach((newBlock) => {
// Note: Don't remap the parent of the new root block
if (newBlock.id !== newRootBlockId && newBlock.parentId) {
@ -112,7 +112,8 @@ class OctoUtils {
}
})
return [newBlocks, idMap]
const newRootBlock = newBlocks.find((block) => block.id === newRootBlockId)!
return [newBlocks, newRootBlock, idMap]
}
}

View File

@ -143,7 +143,7 @@ export default class BoardPage extends React.Component<Props, State> {
const workspaceTree = new MutableWorkspaceTree()
await workspaceTree.sync()
const boardIds = workspaceTree.boards.map((o) => o.id)
const boardIds = [...workspaceTree.boards.map((o) => o.id), ...workspaceTree.boardTemplates.map((o) => o.id)]
this.setState({workspaceTree})
// Listen to boards plus all blocks at root (Empty string for parentId)

View File

@ -100,6 +100,10 @@ hr {
overflow: scroll;
padding: 10px 95px 50px 95px;
@media screen and (max-width: 768px) {
padding: 10px 10px 50px 10px;
}
}
.dragover {
@ -173,6 +177,7 @@ hr {
}
.octo-block img {
width: calc(100% - 20px);
max-width: 500px;
max-height: 500px;
margin: 5px 0;
@ -189,7 +194,13 @@ hr {
align-items: flex-start;
width: 100%;
@media not screen and (max-width: 975px) {
padding-right: 126px;
}
@media screen and (max-width: 975px) {
padding-right: 10px;
}
> * {
flex: 1 1 auto;
@ -207,5 +218,7 @@ hr {
padding-top: 10px;
padding-right: 10px;
@media not screen and (max-width: 975px) {
width: 126px;
}
}

View File

@ -7,6 +7,7 @@ import {Utils} from './utils'
test('Basic undo/redo', async () => {
expect(undoManager.canUndo).toBe(false)
expect(undoManager.canRedo).toBe(false)
expect(undoManager.currentCheckpoint).toBe(0)
const values: string[] = []
@ -22,6 +23,7 @@ test('Basic undo/redo', async () => {
expect(undoManager.canUndo).toBe(true)
expect(undoManager.canRedo).toBe(false)
expect(undoManager.currentCheckpoint).toBeGreaterThan(0)
expect(Utils.arraysEqual(values, ['a'])).toBe(true)
expect(undoManager.undoDescription).toBe('test')
expect(undoManager.redoDescription).toBe(undefined)
@ -41,6 +43,7 @@ test('Basic undo/redo', async () => {
await undoManager.clear()
expect(undoManager.canUndo).toBe(false)
expect(undoManager.canRedo).toBe(false)
expect(undoManager.currentCheckpoint).toBe(0)
expect(undoManager.undoDescription).toBe(undefined)
expect(undoManager.redoDescription).toBe(undefined)
})

View File

@ -49,7 +49,7 @@ class Utils {
// HACKHACK: Somehow, marked doesn't encode angle brackets
const renderer = new marked.Renderer()
renderer.link = (href, title, contents) => `<a target="_blank" href="${href}" title="${title || ''}" onclick="event.stopPropagation();">${contents}</a>`
const html = marked(text.replace(/</g, '&lt;'), {renderer})
const html = marked(text.replace(/</g, '&lt;'), {renderer, breaks: true})
return html
}

View File

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IBlock, MutableBlock} from '../blocks/block'
import {IBlock} from '../blocks/block'
import {Card, MutableCard} from '../blocks/card'
import {IOrderedBlock} from '../blocks/orderedBlock'
import octoClient from '../octoClient'
@ -12,7 +12,6 @@ interface CardTree {
readonly contents: readonly IOrderedBlock[]
mutableCopy(): MutableCardTree
templateCopy(): MutableCardTree
}
class MutableCardTree implements CardTree {
@ -56,20 +55,6 @@ class MutableCardTree implements CardTree {
cardTree.incrementalUpdate(this.rawBlocks)
return cardTree
}
templateCopy(): MutableCardTree {
const card = this.card.duplicate()
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}

View File

@ -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[]

View File

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

View File

@ -10,7 +10,7 @@ type Props = {
icon?: React.ReactNode
}
export default class Button extends React.PureComponent<Props> {
export default class IconButton extends React.PureComponent<Props> {
render(): JSX.Element {
return (
<div

View File

@ -24,16 +24,16 @@ export default class Editable extends React.Component<Props> {
}
public focus(): void {
this.elementRef.current!.focus()
// Put cursor at end
document.execCommand('selectAll', false, undefined)
document.getSelection()?.collapseToEnd()
if (this.elementRef.current) {
const valueLength = this.elementRef.current.value.length
this.elementRef.current.focus()
this.elementRef.current.setSelectionRange(valueLength, valueLength)
}
}
public blur = (): void => {
this.saveOnBlur = false
this.elementRef.current!.blur()
this.elementRef.current?.blur()
this.saveOnBlur = true
}

View File

@ -0,0 +1,7 @@
.CloseIcon {
stroke: rgb(var(--main-fg), 0.5);
stroke-width: 4px;
fill: none;
width: 24px;
height: 24px;
}

View File

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import './close.scss'
export default function CloseIcon(): JSX.Element {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
className='CloseIcon Icon'
viewBox='0 0 100 100'
>
<polyline points='30,30 70,70'/>
<polyline points='70,30 30,70'/>
</svg>
)
}

View File

@ -33,32 +33,34 @@
flex-direction: row;
align-items: center;
white-space: nowrap;
font-weight: 400;
padding: 2px 10px;
cursor: pointer;
touch-action: none;
* {
display: flex;
}
&:hover {
background: rgba(90, 90, 90, 0.1);
}
.menu-name {
display: flex;
flex-grow: 1;
white-space: nowrap;
margin-right: 20px;
}
.SubmenuTriangleIcon {
fill: rgba(var(--main-fg), 0.7);
}
.Icon {
width: 16px;
height: 16px;
margin-right: 5px;
}
.IconButton .Icon {
margin-right: 0;
}
}
}