From 0d0bc7a672d8bfa291a17be8bd2f1785796efb42 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Fri, 6 Nov 2020 10:11:01 -0800 Subject: [PATCH 01/61] Incremental updates on websocket --- server/app/blocks.go | 5 +-- server/ws/websockets.go | 58 +++++++++++++++++++++++---- webapp/src/components/cardDetail.tsx | 13 +++--- webapp/src/octoListener.ts | 36 ++++++++++++++--- webapp/src/octoUtils.tsx | 8 ++++ webapp/src/pages/boardPage.tsx | 20 +++++++-- webapp/src/viewModel/boardTree.ts | 18 ++++++++- webapp/src/viewModel/cardTree.ts | 23 +++++++++-- webapp/src/viewModel/workspaceTree.ts | 34 +++++++++++++--- 9 files changed, 178 insertions(+), 37 deletions(-) diff --git a/server/app/blocks.go b/server/app/blocks.go index 7cdddb1c5..757057d01 100644 --- a/server/app/blocks.go +++ b/server/app/blocks.go @@ -44,11 +44,10 @@ func (a *App) InsertBlocks(blocks []model.Block) error { return err } + a.wsServer.BroadcastBlockChange(block) go a.webhook.NotifyUpdate(block) } - a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify) - return nil } @@ -76,7 +75,7 @@ func (a *App) DeleteBlock(blockID string) error { return err } - a.wsServer.BroadcastBlockChangeToWebsocketClients(blockIDsToNotify) + a.wsServer.BroadcastBlockDelete(blockID, parentID) return nil } diff --git a/server/ws/websockets.go b/server/ws/websockets.go index c68ef4d78..a32029a87 100644 --- a/server/ws/websockets.go +++ b/server/ws/websockets.go @@ -5,9 +5,11 @@ import ( "log" "net/http" "sync" + "time" "github.com/gorilla/mux" "github.com/gorilla/websocket" + "github.com/mattermost/mattermost-octo-tasks/server/model" ) // RegisterRoutes registers routes. @@ -98,10 +100,10 @@ func NewServer() *Server { } } -// WebsocketMsg is sent on block changes. -type WebsocketMsg struct { - Action string `json:"action"` - BlockID string `json:"blockId"` +// UpdateMsg is sent on block updates +type UpdateMsg struct { + Action string `json:"action"` + Block model.Block `json:"block"` } // WebsocketCommand is an incoming command from the client. @@ -166,15 +168,27 @@ func (ws *Server) handleWebSocketOnChange(w http.ResponseWriter, r *http.Request } } -// BroadcastBlockChangeToWebsocketClients broadcasts change to clients. -func (ws *Server) BroadcastBlockChangeToWebsocketClients(blockIDs []string) { +// BroadcastBlockDelete broadcasts delete messages to clients +func (ws *Server) BroadcastBlockDelete(blockID string, parentID string) { + now := time.Now().Unix() + block := model.Block{} + block.ID = blockID + block.ParentID = parentID + block.UpdateAt = now + block.DeleteAt = now + + ws.BroadcastBlockChange(block) +} + +/* +func (ws *Server) BroadcastBlockDelete1(blockIDs []string) { for _, blockID := range blockIDs { listeners := ws.GetListeners(blockID) log.Printf("%d listener(s) for blockID: %s", len(listeners), blockID) if listeners != nil { - message := WebsocketMsg{ - Action: "UPDATE_BLOCK", + message := DeleteMsg{ + Action: "DELETE_BLOCK", BlockID: blockID, } @@ -190,3 +204,31 @@ func (ws *Server) BroadcastBlockChangeToWebsocketClients(blockIDs []string) { } } } +*/ + +// BroadcastBlockChange broadcasts update messages to clients +func (ws *Server) BroadcastBlockChange(block model.Block) { + blockIDsToNotify := []string{block.ID, block.ParentID} + + for _, blockID := range blockIDsToNotify { + listeners := ws.GetListeners(blockID) + log.Printf("%d listener(s) for blockID: %s", len(listeners), blockID) + + if listeners != nil { + message := UpdateMsg{ + Action: "UPDATE_BLOCK", + Block: block, + } + + for _, listener := range listeners { + log.Printf("Broadcast change, blockID: %s, remoteAddr: %s", blockID, listener.RemoteAddr()) + + err := listener.WriteJSON(message) + if err != nil { + log.Printf("broadcast error: %v", err) + listener.Close() + } + } + } + } +} diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index d52442a51..cd4d05d5f 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -54,13 +54,14 @@ class CardDetail extends React.Component { } componentDidMount() { - this.cardListener = new OctoListener() - this.cardListener.open([this.props.cardId], async (blockId) => { - Utils.log(`cardListener.onChanged: ${blockId}`) - await cardTree.sync() - this.setState({cardTree}) - }) const cardTree = new MutableCardTree(this.props.cardId) + this.cardListener = new OctoListener() + this.cardListener.open([this.props.cardId], async (blocks) => { + Utils.log(`cardListener.onChanged: ${blocks.length}`) + const newCardTree = cardTree.mutableCopy() + newCardTree.incrementalUpdate(blocks) + this.setState({cardTree: newCardTree}) + }) cardTree.sync().then(() => { this.setState({cardTree, title: cardTree.card.title}) setTimeout(() => { diff --git a/webapp/src/octoListener.ts b/webapp/src/octoListener.ts index ecf07dd09..8e8ff136f 100644 --- a/webapp/src/octoListener.ts +++ b/webapp/src/octoListener.ts @@ -1,5 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {IBlock} from './blocks/block' import {Utils} from './utils' // These are outgoing commands to the server @@ -12,8 +13,11 @@ type WSCommand = { type WSMessage = { action: string blockId: string + block: IBlock } +type OnChangeHandler = (blocks: IBlock[]) => void + // // OctoListener calls a handler when a block or any of its children changes // @@ -27,6 +31,10 @@ class OctoListener { private blockIds: string[] = [] private isInitialized = false + private onChange: OnChangeHandler + private updatedBlocks: IBlock[] = [] + private updateTimeout: NodeJS.Timeout + notificationDelay = 200 reopenDelay = 3000 @@ -35,13 +43,15 @@ class OctoListener { Utils.log(`OctoListener serverUrl: ${this.serverUrl}`) } - open(blockIds: string[], onChange: (blockId: string) => void) { + open(blockIds: string[], onChange: OnChangeHandler) { let timeoutId: NodeJS.Timeout if (this.ws) { this.close() } + this.onChange = onChange + const url = new URL(this.serverUrl) const wsServerUrl = `ws://${url.host}${url.pathname}ws/onchange` Utils.log(`OctoListener open: ${wsServerUrl}`) @@ -84,10 +94,7 @@ class OctoListener { if (timeoutId) { clearTimeout(timeoutId) } - timeoutId = setTimeout(() => { - timeoutId = undefined - onChange(message.blockId) - }, this.notificationDelay) + this.queueUpdateNotification(message.block) break default: Utils.logError(`Unexpected action: ${message.action}`) @@ -109,6 +116,7 @@ class OctoListener { const ws = this.ws this.ws = undefined this.blockIds = [] + this.onChange = undefined this.isInitialized = false ws.close() } @@ -151,6 +159,24 @@ class OctoListener { } } } + + private queueUpdateNotification(block: IBlock) { + this.updatedBlocks = this.updatedBlocks.filter((o) => o.id !== block.id) // Remove existing queued update + this.updatedBlocks.push(block) + if (this.updateTimeout) { + clearTimeout(this.updateTimeout) + this.updateTimeout = undefined + } + + this.updateTimeout = setTimeout(() => { + this.flushUpdateNotifications() + }, this.notificationDelay) + } + + private flushUpdateNotifications() { + this.onChange(this.updatedBlocks) + this.updatedBlocks = [] + } } export {OctoListener} diff --git a/webapp/src/octoUtils.tsx b/webapp/src/octoUtils.tsx index 81b0d020d..14c10e596 100644 --- a/webapp/src/octoUtils.tsx +++ b/webapp/src/octoUtils.tsx @@ -80,6 +80,14 @@ class OctoUtils { static hydrateBlocks(blocks: IBlock[]): MutableBlock[] { return blocks.map((block) => this.hydrateBlock(block)) } + + static mergeBlocks(blocks: IBlock[], updatedBlocks: IBlock[]): IBlock[] { + const updatedBlockIds = updatedBlocks.map((o) => o.id) + const newBlocks = blocks.filter((o) => !updatedBlockIds.includes(o.id)) + const updatedAndNotDeletedBlocks = updatedBlocks.filter((o) => o.deleteAt === 0) + newBlocks.push(...updatedAndNotDeletedBlocks) + return newBlocks + } } export {OctoUtils} diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index 2a32c24a5..38ead3e99 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -10,6 +10,7 @@ import mutator from '../mutator' import {OctoListener} from '../octoListener' import {Utils} from '../utils' import {MutableWorkspaceTree} from '../viewModel/workspaceTree' +import {IBlock} from '../blocks/block' type Props = { setLanguage: (lang: string) => void @@ -151,9 +152,9 @@ export default class BoardPage extends React.Component { const boardIds = workspaceTree.boards.map((o) => o.id) // Listen to boards plus all blocks at root (Empty string for parentId) - this.workspaceListener.open(['', ...boardIds], async (blockId) => { - Utils.log(`workspaceListener.onChanged: ${blockId}`) - this.sync() + this.workspaceListener.open(['', ...boardIds], async (blocks) => { + Utils.log(`workspaceListener.onChanged: ${blocks.length}`) + this.incrementalUpdate(blocks) }) if (boardId) { @@ -179,6 +180,19 @@ export default class BoardPage extends React.Component { } } + private incrementalUpdate(blocks: IBlock[]) { + const {workspaceTree, boardTree, viewId} = this.state + + const newWorkspaceTree = workspaceTree.mutableCopy() + newWorkspaceTree.incrementalUpdate(blocks) + + const newBoardTree = boardTree.mutableCopy() + newBoardTree.incrementalUpdate(blocks) + newBoardTree.setActiveView(viewId) + + this.setState({workspaceTree: newWorkspaceTree, boardTree: newBoardTree}) + } + // IPageController showBoard(boardId: string): void { const {boardTree} = this.state diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index c961694d1..f0902eb1b 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -29,6 +29,8 @@ interface BoardTree { getSearchText(): string | undefined orderedCards(): Card[] + + mutableCopy(): MutableBoardTree } class MutableBoardTree implements BoardTree { @@ -41,6 +43,7 @@ class MutableBoardTree implements BoardTree { activeView?: MutableBoardView groupByProperty?: IPropertyTemplate + private rawBlocks: IBlock[] = [] private searchText?: string allCards: MutableCard[] = [] get allBlocks(): IBlock[] { @@ -51,8 +54,13 @@ class MutableBoardTree implements BoardTree { } async sync() { - const blocks = await octoClient.getSubtree(this.boardId) - this.rebuild(OctoUtils.hydrateBlocks(blocks)) + this.rawBlocks = await octoClient.getSubtree(this.boardId) + this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) + } + + incrementalUpdate(updatedBlocks: IBlock[]) { + this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, updatedBlocks) + this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } private rebuild(blocks: IMutableBlock[]) { @@ -390,6 +398,12 @@ class MutableBoardTree implements BoardTree { return cards } + + mutableCopy(): MutableBoardTree { + const boardTree = new MutableBoardTree(this.boardId) + boardTree.incrementalUpdate(this.rawBlocks) + return boardTree + } } export {MutableBoardTree, BoardTree, Group as BoardTreeGroup} diff --git a/webapp/src/viewModel/cardTree.ts b/webapp/src/viewModel/cardTree.ts index c0b7dace1..98490fc85 100644 --- a/webapp/src/viewModel/cardTree.ts +++ b/webapp/src/viewModel/cardTree.ts @@ -10,19 +10,28 @@ interface CardTree { readonly card: Card readonly comments: readonly IBlock[] readonly contents: readonly IOrderedBlock[] + + mutableCopy(): MutableCardTree } class MutableCardTree implements CardTree { card: Card - comments: IBlock[] - contents: IOrderedBlock[] + comments: IBlock[] = [] + contents: IOrderedBlock[] = [] + + private rawBlocks: IBlock[] = [] constructor(private cardId: string) { } async sync() { - const blocks = await octoClient.getSubtree(this.cardId) - this.rebuild(OctoUtils.hydrateBlocks(blocks)) + this.rawBlocks = await octoClient.getSubtree(this.cardId) + this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) + } + + incrementalUpdate(updatedBlocks: IBlock[]) { + this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, updatedBlocks) + this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } private rebuild(blocks: IBlock[]) { @@ -35,6 +44,12 @@ class MutableCardTree implements CardTree { const contentBlocks = blocks.filter((block) => block.type === 'text' || block.type === 'image' || block.type === 'divider') as IOrderedBlock[] this.contents = contentBlocks.sort((a, b) => a.order - b.order) } + + mutableCopy(): MutableCardTree { + const cardTree = new MutableCardTree(this.cardId) + cardTree.incrementalUpdate(this.rawBlocks) + return cardTree + } } export {MutableCardTree, CardTree} diff --git a/webapp/src/viewModel/workspaceTree.ts b/webapp/src/viewModel/workspaceTree.ts index cb8f595a4..65b43c0c9 100644 --- a/webapp/src/viewModel/workspaceTree.ts +++ b/webapp/src/viewModel/workspaceTree.ts @@ -9,18 +9,35 @@ import {BoardView} from '../blocks/boardView' interface WorkspaceTree { readonly boards: readonly Board[] readonly views: readonly BoardView[] + + mutableCopy(): MutableWorkspaceTree } class MutableWorkspaceTree { boards: Board[] = [] views: BoardView[] = [] + private rawBoards: IBlock[] = [] + private rawViews: IBlock[] = [] + async sync() { - const boards = await octoClient.getBlocksWithType('board') - const views = await octoClient.getBlocksWithType('view') + this.rawBoards = await octoClient.getBlocksWithType('board') + this.rawViews = await octoClient.getBlocksWithType('view') this.rebuild( - OctoUtils.hydrateBlocks(boards), - OctoUtils.hydrateBlocks(views), + OctoUtils.hydrateBlocks(this.rawBoards), + OctoUtils.hydrateBlocks(this.rawViews), + ) + } + + incrementalUpdate(updatedBlocks: IBlock[]) { + const updatedBoards = updatedBlocks.filter((o) => o.type === 'board') + const updatedViews = updatedBlocks.filter((o) => o.type === 'view') + + this.rawBoards = OctoUtils.mergeBlocks(this.rawBoards, updatedBoards) + this.rawViews = OctoUtils.mergeBlocks(this.rawViews, updatedViews) + this.rebuild( + OctoUtils.hydrateBlocks(this.rawBoards), + OctoUtils.hydrateBlocks(this.rawViews), ) } @@ -28,8 +45,13 @@ class MutableWorkspaceTree { this.boards = boards.filter((block) => block.type === 'board') as Board[] this.views = views.filter((block) => block.type === 'view') as BoardView[] } + + mutableCopy(): MutableWorkspaceTree { + const workspaceTree = new MutableWorkspaceTree() + const rawBlocks = [...this.rawBoards, ...this.rawViews] + workspaceTree.incrementalUpdate(rawBlocks) + return workspaceTree + } } -// type WorkspaceTree = Readonly - export {MutableWorkspaceTree, WorkspaceTree} From 40623db4c5c93aad58f96cccd3935b46e58299dd Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Fri, 6 Nov 2020 10:17:33 -0800 Subject: [PATCH 02/61] Sync on reconnect --- webapp/src/components/cardDetail.tsx | 20 ++++++++++++++------ webapp/src/octoListener.ts | 8 +++++--- webapp/src/pages/boardPage.tsx | 14 ++++++++++---- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index cd4d05d5f..05ab1fcd0 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -56,12 +56,20 @@ class CardDetail extends React.Component { componentDidMount() { const cardTree = new MutableCardTree(this.props.cardId) this.cardListener = new OctoListener() - this.cardListener.open([this.props.cardId], async (blocks) => { - Utils.log(`cardListener.onChanged: ${blocks.length}`) - const newCardTree = cardTree.mutableCopy() - newCardTree.incrementalUpdate(blocks) - this.setState({cardTree: newCardTree}) - }) + this.cardListener.open( + [this.props.cardId], + async (blocks) => { + Utils.log(`cardListener.onChanged: ${blocks.length}`) + const newCardTree = cardTree.mutableCopy() + newCardTree.incrementalUpdate(blocks) + this.setState({cardTree: newCardTree, title: cardTree.card.title}) + }, + async () => { + Utils.log(`cardListener.onReconnect`) + const newCardTree = cardTree.mutableCopy() + await newCardTree.sync() + this.setState({cardTree: newCardTree, title: newCardTree.card.title}) + }) cardTree.sync().then(() => { this.setState({cardTree, title: cardTree.card.title}) setTimeout(() => { diff --git a/webapp/src/octoListener.ts b/webapp/src/octoListener.ts index 8e8ff136f..fd425111f 100644 --- a/webapp/src/octoListener.ts +++ b/webapp/src/octoListener.ts @@ -43,7 +43,7 @@ class OctoListener { Utils.log(`OctoListener serverUrl: ${this.serverUrl}`) } - open(blockIds: string[], onChange: OnChangeHandler) { + open(blockIds: string[], onChange: OnChangeHandler, onReconnect: () => void) { let timeoutId: NodeJS.Timeout if (this.ws) { @@ -75,13 +75,14 @@ class OctoListener { const reopenBlockIds = this.isInitialized ? this.blockIds.slice() : blockIds.slice() Utils.logError(`Unexpected close, re-opening with ${reopenBlockIds.length} blocks...`) setTimeout(() => { - this.open(reopenBlockIds, onChange) + this.open(reopenBlockIds, onChange, onReconnect) + onReconnect() }, this.reopenDelay) } } ws.onmessage = (e) => { - Utils.log(`OctoListener websocket onmessage. data: ${e.data}`) + // Utils.log(`OctoListener websocket onmessage. data: ${e.data}`) if (ws !== this.ws) { Utils.log('Ignoring closed ws') return @@ -94,6 +95,7 @@ class OctoListener { if (timeoutId) { clearTimeout(timeoutId) } + Utils.log(`OctoListener update block: ${message.block?.id}`) this.queueUpdateNotification(message.block) break default: diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index 38ead3e99..a74ae455a 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -152,10 +152,16 @@ export default class BoardPage extends React.Component { const boardIds = workspaceTree.boards.map((o) => o.id) // Listen to boards plus all blocks at root (Empty string for parentId) - this.workspaceListener.open(['', ...boardIds], async (blocks) => { - Utils.log(`workspaceListener.onChanged: ${blocks.length}`) - this.incrementalUpdate(blocks) - }) + this.workspaceListener.open( + ['', ...boardIds], + async (blocks) => { + Utils.log(`workspaceListener.onChanged: ${blocks.length}`) + this.incrementalUpdate(blocks) + }, + () => { + Utils.log(`workspaceListener.onReconnect`) + this.sync() + }) if (boardId) { const boardTree = new MutableBoardTree(boardId) From 4a7bf165c4a11e1a295ee0938ed390f086b79db7 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Fri, 6 Nov 2020 10:20:03 -0800 Subject: [PATCH 03/61] cleanup --- server/ws/websockets.go | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/server/ws/websockets.go b/server/ws/websockets.go index a32029a87..a0c665efe 100644 --- a/server/ws/websockets.go +++ b/server/ws/websockets.go @@ -180,32 +180,6 @@ func (ws *Server) BroadcastBlockDelete(blockID string, parentID string) { ws.BroadcastBlockChange(block) } -/* -func (ws *Server) BroadcastBlockDelete1(blockIDs []string) { - for _, blockID := range blockIDs { - listeners := ws.GetListeners(blockID) - log.Printf("%d listener(s) for blockID: %s", len(listeners), blockID) - - if listeners != nil { - message := DeleteMsg{ - Action: "DELETE_BLOCK", - BlockID: blockID, - } - - for _, listener := range listeners { - log.Printf("Broadcast change, blockID: %s, remoteAddr: %s", blockID, listener.RemoteAddr()) - - err := listener.WriteJSON(message) - if err != nil { - log.Printf("broadcast error: %v", err) - listener.Close() - } - } - } - } -} -*/ - // BroadcastBlockChange broadcasts update messages to clients func (ws *Server) BroadcastBlockChange(block model.Block) { blockIDsToNotify := []string{block.ID, block.ParentID} From 9a40ada10d665a707b8900c3b2a9f23762d8dcd3 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Fri, 6 Nov 2020 11:59:40 -0800 Subject: [PATCH 04/61] Handle delete boards correctly --- webapp/src/pages/boardPage.tsx | 2 +- webapp/src/viewModel/workspaceTree.ts | 33 +++++++++------------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index a74ae455a..c4801332b 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -192,7 +192,7 @@ export default class BoardPage extends React.Component { const newWorkspaceTree = workspaceTree.mutableCopy() newWorkspaceTree.incrementalUpdate(blocks) - const newBoardTree = boardTree.mutableCopy() + const newBoardTree = boardTree ? boardTree.mutableCopy() : new MutableBoardTree(this.state.boardId) newBoardTree.incrementalUpdate(blocks) newBoardTree.setActiveView(viewId) diff --git a/webapp/src/viewModel/workspaceTree.ts b/webapp/src/viewModel/workspaceTree.ts index 65b43c0c9..c348a8393 100644 --- a/webapp/src/viewModel/workspaceTree.ts +++ b/webapp/src/viewModel/workspaceTree.ts @@ -17,39 +17,28 @@ class MutableWorkspaceTree { boards: Board[] = [] views: BoardView[] = [] - private rawBoards: IBlock[] = [] - private rawViews: IBlock[] = [] + private rawBlocks: IBlock[] = [] async sync() { - this.rawBoards = await octoClient.getBlocksWithType('board') - this.rawViews = await octoClient.getBlocksWithType('view') - this.rebuild( - OctoUtils.hydrateBlocks(this.rawBoards), - OctoUtils.hydrateBlocks(this.rawViews), - ) + const rawBoards = await octoClient.getBlocksWithType('board') + const rawViews = await octoClient.getBlocksWithType('view') + this.rawBlocks = [...rawBoards, ...rawViews] + this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } incrementalUpdate(updatedBlocks: IBlock[]) { - const updatedBoards = updatedBlocks.filter((o) => o.type === 'board') - const updatedViews = updatedBlocks.filter((o) => o.type === 'view') - - this.rawBoards = OctoUtils.mergeBlocks(this.rawBoards, updatedBoards) - this.rawViews = OctoUtils.mergeBlocks(this.rawViews, updatedViews) - this.rebuild( - OctoUtils.hydrateBlocks(this.rawBoards), - OctoUtils.hydrateBlocks(this.rawViews), - ) + this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, updatedBlocks) + this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } - private rebuild(boards: IBlock[], views: IBlock[]) { - this.boards = boards.filter((block) => block.type === 'board') as Board[] - this.views = views.filter((block) => block.type === 'view') as BoardView[] + private rebuild(blocks: IBlock[]) { + this.boards = blocks.filter((block) => block.type === 'board') as Board[] + this.views = blocks.filter((block) => block.type === 'view') as BoardView[] } mutableCopy(): MutableWorkspaceTree { const workspaceTree = new MutableWorkspaceTree() - const rawBlocks = [...this.rawBoards, ...this.rawViews] - workspaceTree.incrementalUpdate(rawBlocks) + workspaceTree.incrementalUpdate(this.rawBlocks) return workspaceTree } } From 1378b4d2b42442b0a94e047056f4423b72a26462 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Fri, 6 Nov 2020 12:00:05 -0800 Subject: [PATCH 05/61] Lower notification delay from 200 to 100ms --- webapp/src/octoListener.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/octoListener.ts b/webapp/src/octoListener.ts index fd425111f..da05ad394 100644 --- a/webapp/src/octoListener.ts +++ b/webapp/src/octoListener.ts @@ -35,7 +35,7 @@ class OctoListener { private updatedBlocks: IBlock[] = [] private updateTimeout: NodeJS.Timeout - notificationDelay = 200 + notificationDelay = 100 reopenDelay = 3000 constructor(serverUrl?: string) { From b86372f96b8ed2268aa96d47d6831afc947c3c08 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Fri, 6 Nov 2020 12:48:11 -0800 Subject: [PATCH 06/61] Use default sort order for ties (stable sort) --- webapp/src/viewModel/boardTree.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index f0902eb1b..141820115 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -296,6 +296,12 @@ class MutableBoardTree implements BoardTree { } let result = aValue.localeCompare(bValue) + + if (result === 0) { + // In case of "ties", use the default order + result = this.defaultOrder(a, b) + } + if (sortOption.reversed) { result = -result } @@ -375,6 +381,11 @@ class MutableBoardTree implements BoardTree { result = aValue.localeCompare(bValue) } + if (result === 0) { + // In case of "ties", use the default order + result = this.defaultOrder(a, b) + } + if (sortOption.reversed) { result = -result } From 234676698e9044fb9f7be71ca919cad9cf793663 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Fri, 6 Nov 2020 12:49:17 -0800 Subject: [PATCH 07/61] Fix card title change --- webapp/src/components/cardDetail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index 05ab1fcd0..df9b3bf63 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -62,7 +62,7 @@ class CardDetail extends React.Component { Utils.log(`cardListener.onChanged: ${blocks.length}`) const newCardTree = cardTree.mutableCopy() newCardTree.incrementalUpdate(blocks) - this.setState({cardTree: newCardTree, title: cardTree.card.title}) + this.setState({cardTree: newCardTree, title: newCardTree.card.title}) }, async () => { Utils.log(`cardListener.onReconnect`) From 0cca55126c1b288ca63b240d6076ef3017403837 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Fri, 6 Nov 2020 12:50:25 -0800 Subject: [PATCH 08/61] npm run fix --- webapp/src/components/cardDetail.tsx | 2 +- webapp/src/pages/boardPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index df9b3bf63..bb6b27296 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -65,7 +65,7 @@ class CardDetail extends React.Component { this.setState({cardTree: newCardTree, title: newCardTree.card.title}) }, async () => { - Utils.log(`cardListener.onReconnect`) + Utils.log('cardListener.onReconnect') const newCardTree = cardTree.mutableCopy() await newCardTree.sync() this.setState({cardTree: newCardTree, title: newCardTree.card.title}) diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index c4801332b..fd6cd0610 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -159,7 +159,7 @@ export default class BoardPage extends React.Component { this.incrementalUpdate(blocks) }, () => { - Utils.log(`workspaceListener.onReconnect`) + Utils.log('workspaceListener.onReconnect') this.sync() }) From c00902cb6b8dd115090850f931eeb5b57ee942be Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Fri, 6 Nov 2020 14:07:04 -0800 Subject: [PATCH 09/61] Filter relevant updates --- webapp/src/viewModel/boardTree.ts | 6 +++++- webapp/src/viewModel/cardTree.ts | 6 +++++- webapp/src/viewModel/workspaceTree.ts | 4 ++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index 141820115..bd9a17ada 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -59,7 +59,11 @@ class MutableBoardTree implements BoardTree { } incrementalUpdate(updatedBlocks: IBlock[]) { - this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, updatedBlocks) + const relevantBlocks = updatedBlocks.filter((block) => block.id === this.boardId || block.parentId === this.boardId) + if (relevantBlocks.length < 1) { + return + } + this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, relevantBlocks) this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } diff --git a/webapp/src/viewModel/cardTree.ts b/webapp/src/viewModel/cardTree.ts index 98490fc85..8c8252f46 100644 --- a/webapp/src/viewModel/cardTree.ts +++ b/webapp/src/viewModel/cardTree.ts @@ -30,7 +30,11 @@ class MutableCardTree implements CardTree { } incrementalUpdate(updatedBlocks: IBlock[]) { - this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, updatedBlocks) + const relevantBlocks = updatedBlocks.filter((block) => block.id === this.cardId || block.parentId === this.cardId) + if (relevantBlocks.length < 1) { + return + } + this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, relevantBlocks) this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } diff --git a/webapp/src/viewModel/workspaceTree.ts b/webapp/src/viewModel/workspaceTree.ts index c348a8393..1ab4be484 100644 --- a/webapp/src/viewModel/workspaceTree.ts +++ b/webapp/src/viewModel/workspaceTree.ts @@ -27,6 +27,10 @@ class MutableWorkspaceTree { } incrementalUpdate(updatedBlocks: IBlock[]) { + const relevantBlocks = updatedBlocks.filter((block) => block.type === 'board' || block.type === 'view') + if (relevantBlocks.length < 1) { + return + } this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, updatedBlocks) this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } From 7d2d1c252aa2d2557053a23ba7f5175bc84ec78f Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Fri, 6 Nov 2020 15:10:36 -0800 Subject: [PATCH 10/61] Detect if incemental update resulted in changes --- webapp/src/components/cardDetail.tsx | 5 +++-- webapp/src/pages/boardPage.tsx | 14 ++++++++++---- webapp/src/viewModel/boardTree.ts | 5 +++-- webapp/src/viewModel/cardTree.ts | 5 +++-- webapp/src/viewModel/workspaceTree.ts | 5 +++-- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index bb6b27296..5073482be 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -61,8 +61,9 @@ class CardDetail extends React.Component { async (blocks) => { Utils.log(`cardListener.onChanged: ${blocks.length}`) const newCardTree = cardTree.mutableCopy() - newCardTree.incrementalUpdate(blocks) - this.setState({cardTree: newCardTree, title: newCardTree.card.title}) + if (newCardTree.incrementalUpdate(blocks)) { + this.setState({cardTree: newCardTree, title: newCardTree.card.title}) + } }, async () => { Utils.log('cardListener.onReconnect') diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index fd6cd0610..cc89bed7e 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -189,14 +189,20 @@ export default class BoardPage extends React.Component { private incrementalUpdate(blocks: IBlock[]) { const {workspaceTree, boardTree, viewId} = this.state + let newState = {workspaceTree, boardTree} + const newWorkspaceTree = workspaceTree.mutableCopy() - newWorkspaceTree.incrementalUpdate(blocks) + if (newWorkspaceTree.incrementalUpdate(blocks)) { + newState = {...newState, workspaceTree: newWorkspaceTree} + } const newBoardTree = boardTree ? boardTree.mutableCopy() : new MutableBoardTree(this.state.boardId) - newBoardTree.incrementalUpdate(blocks) - newBoardTree.setActiveView(viewId) + if (newBoardTree.incrementalUpdate(blocks)) { + newBoardTree.setActiveView(viewId) + newState = {...newState, boardTree: newBoardTree} + } - this.setState({workspaceTree: newWorkspaceTree, boardTree: newBoardTree}) + this.setState(newState) } // IPageController diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index bd9a17ada..23452b79d 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -58,13 +58,14 @@ class MutableBoardTree implements BoardTree { this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } - incrementalUpdate(updatedBlocks: IBlock[]) { + incrementalUpdate(updatedBlocks: IBlock[]): boolean { const relevantBlocks = updatedBlocks.filter((block) => block.id === this.boardId || block.parentId === this.boardId) if (relevantBlocks.length < 1) { - return + return false } this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, relevantBlocks) this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) + return true } private rebuild(blocks: IMutableBlock[]) { diff --git a/webapp/src/viewModel/cardTree.ts b/webapp/src/viewModel/cardTree.ts index 8c8252f46..0085236d6 100644 --- a/webapp/src/viewModel/cardTree.ts +++ b/webapp/src/viewModel/cardTree.ts @@ -29,13 +29,14 @@ class MutableCardTree implements CardTree { this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } - incrementalUpdate(updatedBlocks: IBlock[]) { + incrementalUpdate(updatedBlocks: IBlock[]): boolean { const relevantBlocks = updatedBlocks.filter((block) => block.id === this.cardId || block.parentId === this.cardId) if (relevantBlocks.length < 1) { - return + return false } this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, relevantBlocks) this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) + return true } private rebuild(blocks: IBlock[]) { diff --git a/webapp/src/viewModel/workspaceTree.ts b/webapp/src/viewModel/workspaceTree.ts index 1ab4be484..2b7a0a617 100644 --- a/webapp/src/viewModel/workspaceTree.ts +++ b/webapp/src/viewModel/workspaceTree.ts @@ -26,13 +26,14 @@ class MutableWorkspaceTree { this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } - incrementalUpdate(updatedBlocks: IBlock[]) { + incrementalUpdate(updatedBlocks: IBlock[]): boolean { const relevantBlocks = updatedBlocks.filter((block) => block.type === 'board' || block.type === 'view') if (relevantBlocks.length < 1) { - return + return false } this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, updatedBlocks) this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) + return true } private rebuild(blocks: IBlock[]) { From 98e07214e87ecd208f2c4582443c80d7b6d39038 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Sat, 7 Nov 2020 10:50:34 -0800 Subject: [PATCH 11/61] Tidy up makefile and readme --- Makefile | 27 +++++++++++++++++---------- README.md | 34 ++++++++++++++++++---------------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/Makefile b/Makefile index 76bc312a5..59ac52199 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: prebuild clean cleanall server server-linux server-win64 generate watch-server webapp mac-app win-app linux-app +.PHONY: prebuild clean cleanall server server-mac server-linux server-win generate watch-server webapp mac-app win-app linux-app all: server @@ -13,10 +13,15 @@ prebuild: server: cd server; go build -o ../bin/octoserver ./main -server-linux: - cd server; env GOOS=linux GOARCH=amd64 go build -o ../bin/octoserver ./main +server-mac: + mkdir -p bin/mac + cd server; env GOOS=darwin GOARCH=amd64 go build -o ../bin/mac/octoserver ./main -server-win64: +server-linux: + mkdir -p bin/linux + cd server; env GOOS=linux GOARCH=amd64 go build -o ../bin/linux/octoserver ./main + +server-win: cd server; env GOOS=windows GOARCH=amd64 go build -o ../bin/octoserver.exe ./main generate: @@ -43,18 +48,18 @@ watch-server: webapp: cd webapp; npm run pack -mac-app: server webapp +mac-app: server-mac webapp rm -rf mac/resources/bin rm -rf mac/resources/pack - mkdir -p mac/resources - cp -R bin mac/resources/bin + mkdir -p mac/resources/bin + cp bin/mac/octoserver mac/resources/bin/octoserver cp -R webapp/pack mac/resources/pack mkdir -p mac/temp xcodebuild archive -workspace mac/Tasks.xcworkspace -scheme Tasks -archivePath mac/temp/tasks.xcarchive xcodebuild -exportArchive -archivePath mac/temp/tasks.xcarchive -exportPath mac/dist -exportOptionsPlist mac/export.plist - cd mac/dist; zip -r tasks.zip Tasks.app + cd mac/dist; zip -r tasks-mac.zip Tasks.app -win-app: server-win64 webapp +win-app: server-win webapp cd win; make build mkdir -p win/dist/bin cp -R bin/octoserver.exe win/dist/bin @@ -67,7 +72,7 @@ linux-app: server-linux webapp rm -rf linux/temp mkdir -p linux/temp/tasks-app/webapp mkdir -p linux/dist - cp -R bin/octoserver linux/temp/tasks-app/ + cp -R bin/linux/octoserver linux/temp/tasks-app/ cp -R config.json linux/temp/tasks-app/ cp -R webapp/pack linux/temp/tasks-app/webapp/pack cd linux; make build @@ -81,6 +86,8 @@ clean: rm -rf webapp/pack rm -rf mac/temp rm -rf mac/dist + rm -rf linux/dist + rm -rf win/dist cleanall: clean rm -rf webapp/node_modules diff --git a/README.md b/README.md index a9573449f..efdba16cd 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,6 @@ ## Building the server ``` -cd webapp -npm install -npm run packdev -cd .. make prebuild make ``` @@ -15,8 +11,9 @@ Currently tested with: * Go 1.15.2 * MacOS Catalina (10.15.6) * Ubuntu 18.04 +* Windows 10 -The server defaults to using sqlite as the store, but can be configured to use Postgres: +The server defaults to using SQLite as the store, but can be configured to use Postgres: * In config.json * Set dbtype to "postgres" * Set dbconfig to the connection string (which you can copy from dbconfig_postgres) @@ -25,21 +22,26 @@ The server defaults to using sqlite as the store, but can be configured to use P ## Running and testing the server -To start the server: -``` -./bin/octoserver -``` +To start the server, run `./bin/octoserver` Server settings are in config.json. Open a browser to [http://localhost:8000](http://localhost:8000) to start. -## Building and running the macOS app -You can build the Mac app on a Mac running macOS Catalina (10.15.6+) and with Xcode 12.0+. A valid development signing certificate must be available. +## Building and running standalone desktop apps -First build the server using the steps above, then run: -``` -make mac -``` +You can build standalone apps that package the server to run locally against SQLite: -To run, launch mac/dist/Tasks.app +* Mac: + * `make mac-app` + * run `mac/dist/Tasks.app` + * *Requires: macOS Catalina (10.15), Xcode 12 and a development signing certificate.* +* Linux: + * `make linux-app` + * run `linux/dist/tasks-app` +* Windows + * `make win-app` + * run `win/dist/tasks-win.exe` + * *Requires: Windows 10* + +Cross-compilation currently isn't fully supported, so please build on the appropriate platform. From 1111bd337a8ffec473a6d2bacf9000acba1d0d04 Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Mon, 9 Nov 2020 13:19:03 +0100 Subject: [PATCH 12/61] Add API client, integration tests structure and server lifecycle changes --- server/app/files.go | 18 +- server/client/client.go | 186 +++++++++++++++++++ server/integrationtests/blocks_test.go | 218 +++++++++++++++++++++++ server/integrationtests/clienttestlib.go | 54 ++++++ server/model/block.go | 11 ++ server/server/server.go | 8 + server/utils/utils.go | 19 ++ server/web/webserver.go | 32 ++-- 8 files changed, 520 insertions(+), 26 deletions(-) create mode 100644 server/client/client.go create mode 100644 server/integrationtests/blocks_test.go create mode 100644 server/integrationtests/clienttestlib.go create mode 100644 server/utils/utils.go diff --git a/server/app/files.go b/server/app/files.go index c12506c6e..5e7cd24fa 100644 --- a/server/app/files.go +++ b/server/app/files.go @@ -1,13 +1,13 @@ package app import ( - "crypto/rand" "errors" "fmt" "io" - "log" "path/filepath" "strings" + + "github.com/mattermost/mattermost-octo-tasks/server/utils" ) func (a *App) SaveFile(reader io.Reader, filename string) (string, error) { @@ -17,7 +17,7 @@ func (a *App) SaveFile(reader io.Reader, filename string) (string, error) { fileExtension = ".jpg" } - createdFilename := fmt.Sprintf(`%s%s`, createGUID(), fileExtension) + createdFilename := fmt.Sprintf(`%s%s`, utils.CreateGUID(), fileExtension) _, appErr := a.filesBackend.WriteFile(reader, createdFilename) if appErr != nil { @@ -32,15 +32,3 @@ func (a *App) GetFilePath(filename string) string { return filepath.Join(folderPath, filename) } - -// CreateGUID returns a random GUID. -func createGUID() string { - b := make([]byte, 16) - _, err := rand.Read(b) - if err != nil { - log.Fatal(err) - } - uuid := fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) - - return uuid -} diff --git a/server/client/client.go b/server/client/client.go new file mode 100644 index 000000000..c586c531d --- /dev/null +++ b/server/client/client.go @@ -0,0 +1,186 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + + "github.com/mattermost/mattermost-octo-tasks/server/model" +) + +const ( + API_URL_SUFFIX = "/api/v1" +) + +type Response struct { + StatusCode int + Error error + Header http.Header +} + +func BuildResponse(r *http.Response) *Response { + return &Response{ + StatusCode: r.StatusCode, + Header: r.Header, + } +} + +func BuildErrorResponse(r *http.Response, err error) *Response { + statusCode := 0 + header := make(http.Header) + if r != nil { + statusCode = r.StatusCode + header = r.Header + } + + return &Response{ + StatusCode: statusCode, + Error: err, + Header: header, + } +} + +func closeBody(r *http.Response) { + if r.Body != nil { + _, _ = io.Copy(ioutil.Discard, r.Body) + _ = r.Body.Close() + } +} + +func toJSON(v interface{}) string { + b, _ := json.Marshal(v) + return string(b) +} + +type Client struct { + Url string + ApiUrl string + HttpClient *http.Client + HttpHeader map[string]string +} + +func NewClient(url string) *Client { + url = strings.TrimRight(url, "/") + return &Client{url, url + API_URL_SUFFIX, &http.Client{}, map[string]string{}} +} + +func (c *Client) DoApiGet(url string, etag string) (*http.Response, error) { + return c.DoApiRequest(http.MethodGet, c.ApiUrl+url, "", etag) +} + +func (c *Client) DoApiPost(url string, data string) (*http.Response, error) { + return c.DoApiRequest(http.MethodPost, c.ApiUrl+url, data, "") +} + +func (c *Client) doApiPostBytes(url string, data []byte) (*http.Response, error) { + return c.doApiRequestBytes(http.MethodPost, c.ApiUrl+url, data, "") +} + +func (c *Client) DoApiPut(url string, data string) (*http.Response, error) { + return c.DoApiRequest(http.MethodPut, c.ApiUrl+url, data, "") +} + +func (c *Client) doApiPutBytes(url string, data []byte) (*http.Response, error) { + return c.doApiRequestBytes(http.MethodPut, c.ApiUrl+url, data, "") +} + +func (c *Client) DoApiDelete(url string) (*http.Response, error) { + return c.DoApiRequest(http.MethodDelete, c.ApiUrl+url, "", "") +} + +func (c *Client) DoApiRequest(method, url, data, etag string) (*http.Response, error) { + return c.doApiRequestReader(method, url, strings.NewReader(data), etag) +} + +func (c *Client) doApiRequestBytes(method, url string, data []byte, etag string) (*http.Response, error) { + return c.doApiRequestReader(method, url, bytes.NewReader(data), etag) +} + +func (c *Client) doApiRequestReader(method, url string, data io.Reader, etag string) (*http.Response, error) { + rq, err := http.NewRequest(method, url, data) + if err != nil { + return nil, err + } + + if c.HttpHeader != nil && len(c.HttpHeader) > 0 { + for k, v := range c.HttpHeader { + rq.Header.Set(k, v) + } + } + + rp, err := c.HttpClient.Do(rq) + if err != nil || rp == nil { + return nil, err + } + + if rp.StatusCode == 304 { + return rp, nil + } + + if rp.StatusCode >= 300 { + defer closeBody(rp) + b, err := ioutil.ReadAll(rp.Body) + if err != nil { + return rp, fmt.Errorf("error when parsing response with code %d: %w", rp.StatusCode, err) + } + return rp, fmt.Errorf(string(b)) + } + + return rp, nil +} + +func (c *Client) GetBlocksRoute() string { + return "/blocks" +} + +func (c *Client) GetBlockRoute(id string) string { + return fmt.Sprintf("%s/%s", c.GetBlocksRoute(), id) +} + +func (c *Client) GetSubtreeRoute(id string) string { + return fmt.Sprintf("%s/subtree", c.GetBlockRoute(id)) +} + +func (c *Client) GetBlocks() ([]model.Block, *Response) { + r, err := c.DoApiGet(c.GetBlocksRoute(), "") + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + + return model.BlocksFromJSON(r.Body), BuildResponse(r) +} + +func (c *Client) InsertBlocks(blocks []model.Block) (bool, *Response) { + r, err := c.DoApiPost(c.GetBlocksRoute(), toJSON(blocks)) + if err != nil { + return false, BuildErrorResponse(r, err) + } + defer closeBody(r) + + return true, BuildResponse(r) +} + +func (c *Client) DeleteBlock(blockID string) (bool, *Response) { + r, err := c.DoApiDelete(c.GetBlockRoute(blockID)) + if err != nil { + return false, BuildErrorResponse(r, err) + } + defer closeBody(r) + + return true, BuildResponse(r) +} + +func (c *Client) GetSubtree(blockID string) ([]model.Block, *Response) { + r, err := c.DoApiGet(c.GetSubtreeRoute(blockID), "") + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + + return model.BlocksFromJSON(r.Body), BuildResponse(r) +} diff --git a/server/integrationtests/blocks_test.go b/server/integrationtests/blocks_test.go new file mode 100644 index 000000000..28379050c --- /dev/null +++ b/server/integrationtests/blocks_test.go @@ -0,0 +1,218 @@ +package integrationtests + +import ( + "testing" + + "github.com/mattermost/mattermost-octo-tasks/server/model" + "github.com/mattermost/mattermost-octo-tasks/server/utils" + + "github.com/stretchr/testify/require" +) + +func TestGetBlocks(t *testing.T) { + th := SetupTestHelper().InitBasic() + defer th.TearDown() + + blockID1 := utils.CreateGUID() + blockID2 := utils.CreateGUID() + newBlocks := []model.Block{ + { + ID: blockID1, + CreateAt: 1, + UpdateAt: 1, + Type: "board", + }, + { + ID: blockID2, + CreateAt: 1, + UpdateAt: 1, + Type: "board", + }, + } + _, resp := th.Client.InsertBlocks(newBlocks) + require.NoError(t, resp.Error) + + blocks, resp := th.Client.GetBlocks() + require.NoError(t, resp.Error) + require.Len(t, blocks, 2) + + blockIDs := make([]string, len(blocks)) + for i, b := range blocks { + blockIDs[i] = b.ID + } + require.Contains(t, blockIDs, blockID1) + require.Contains(t, blockIDs, blockID2) +} + +func TestPostBlock(t *testing.T) { + th := SetupTestHelper().InitBasic() + defer th.TearDown() + + blockID1 := utils.CreateGUID() + blockID2 := utils.CreateGUID() + blockID3 := utils.CreateGUID() + + t.Run("Create a single block", func(t *testing.T) { + block := model.Block{ + ID: blockID1, + CreateAt: 1, + UpdateAt: 1, + Type: "board", + Title: "New title", + } + + _, resp := th.Client.InsertBlocks([]model.Block{block}) + require.NoError(t, resp.Error) + + blocks, resp := th.Client.GetBlocks() + require.NoError(t, resp.Error) + require.Len(t, blocks, 1) + require.Equal(t, blockID1, blocks[0].ID) + }) + + t.Run("Create a couple of blocks in the same call", func(t *testing.T) { + newBlocks := []model.Block{ + { + ID: blockID2, + CreateAt: 1, + UpdateAt: 1, + Type: "board", + }, + { + ID: blockID3, + CreateAt: 1, + UpdateAt: 1, + Type: "board", + }, + } + + _, resp := th.Client.InsertBlocks(newBlocks) + require.NoError(t, resp.Error) + + blocks, resp := th.Client.GetBlocks() + require.NoError(t, resp.Error) + require.Len(t, blocks, 3) + + blockIDs := make([]string, len(blocks)) + for i, b := range blocks { + blockIDs[i] = b.ID + } + require.Contains(t, blockIDs, blockID1) + require.Contains(t, blockIDs, blockID2) + require.Contains(t, blockIDs, blockID3) + }) + + t.Run("Update a block", func(t *testing.T) { + block := model.Block{ + ID: blockID1, + CreateAt: 1, + UpdateAt: 20, + Type: "board", + Title: "Updated title", + } + + _, resp := th.Client.InsertBlocks([]model.Block{block}) + require.NoError(t, resp.Error) + + blocks, resp := th.Client.GetBlocks() + require.NoError(t, resp.Error) + require.Len(t, blocks, 3) + + var updatedBlock model.Block + for _, b := range blocks { + if b.ID == blockID1 { + updatedBlock = b + } + } + require.NotNil(t, updatedBlock) + require.Equal(t, "Updated title", updatedBlock.Title) + }) +} + +func TestDeleteBlock(t *testing.T) { + th := SetupTestHelper().InitBasic() + defer th.TearDown() + + blockID := utils.CreateGUID() + t.Run("Create a block", func(t *testing.T) { + block := model.Block{ + ID: blockID, + CreateAt: 1, + UpdateAt: 1, + Type: "board", + Title: "New title", + } + + _, resp := th.Client.InsertBlocks([]model.Block{block}) + require.NoError(t, resp.Error) + + blocks, resp := th.Client.GetBlocks() + require.NoError(t, resp.Error) + require.Len(t, blocks, 1) + require.Equal(t, blockID, blocks[0].ID) + }) + + t.Run("Delete a block", func(t *testing.T) { + _, resp := th.Client.DeleteBlock(blockID) + require.NoError(t, resp.Error) + + blocks, resp := th.Client.GetBlocks() + require.NoError(t, resp.Error) + require.Len(t, blocks, 0) + }) +} + +func TestGetSubtree(t *testing.T) { + th := SetupTestHelper().InitBasic() + defer th.TearDown() + + parentBlockID := utils.CreateGUID() + childBlockID1 := utils.CreateGUID() + childBlockID2 := utils.CreateGUID() + t.Run("Create the block structure", func(t *testing.T) { + newBlocks := []model.Block{ + { + ID: parentBlockID, + CreateAt: 1, + UpdateAt: 1, + Type: "board", + }, + { + ID: childBlockID1, + ParentID: parentBlockID, + CreateAt: 2, + UpdateAt: 2, + Type: "card", + }, + { + ID: childBlockID2, + ParentID: parentBlockID, + CreateAt: 2, + UpdateAt: 2, + Type: "card", + }, + } + + _, resp := th.Client.InsertBlocks(newBlocks) + require.NoError(t, resp.Error) + + blocks, resp := th.Client.GetBlocks() + require.NoError(t, resp.Error) + require.Len(t, blocks, 1) + require.Equal(t, parentBlockID, blocks[0].ID) + }) + + t.Run("Get subtree for parent ID", func(t *testing.T) { + blocks, resp := th.Client.GetSubtree(parentBlockID) + require.NoError(t, resp.Error) + require.Len(t, blocks, 3) + + blockIDs := make([]string, len(blocks)) + for i, b := range blocks { + blockIDs[i] = b.ID + } + require.Contains(t, blockIDs, parentBlockID) + require.Contains(t, blockIDs, childBlockID1) + require.Contains(t, blockIDs, childBlockID2) + }) +} diff --git a/server/integrationtests/clienttestlib.go b/server/integrationtests/clienttestlib.go new file mode 100644 index 000000000..233104f1e --- /dev/null +++ b/server/integrationtests/clienttestlib.go @@ -0,0 +1,54 @@ +package integrationtests + +import ( + "net/http" + + "github.com/mattermost/mattermost-octo-tasks/server/client" + "github.com/mattermost/mattermost-octo-tasks/server/server" + "github.com/mattermost/mattermost-octo-tasks/server/services/config" +) + +type TestHelper struct { + Server *server.Server + Client *client.Client +} + +func getTestConfig() *config.Configuration { + return &config.Configuration{ + ServerRoot: "http://localhost:8888", + Port: 8888, + DBType: "sqlite3", + DBConfigString: ":memory:", + WebPath: "./pack", + FilesPath: "./files", + } +} + +func SetupTestHelper() *TestHelper { + th := &TestHelper{} + srv, err := server.New(getTestConfig()) + if err != nil { + panic(err) + } + th.Server = srv + th.Client = client.NewClient(srv.Config().ServerRoot) + + return th +} + +func (th *TestHelper) InitBasic() *TestHelper { + go func() { + if err := th.Server.Start(); err != http.ErrServerClosed { + panic(err) + } + }() + + return th +} + +func (th *TestHelper) TearDown() { + err := th.Server.Shutdown() + if err != nil { + panic(err) + } +} diff --git a/server/model/block.go b/server/model/block.go index a66edecd9..822bdbdce 100644 --- a/server/model/block.go +++ b/server/model/block.go @@ -1,5 +1,10 @@ package model +import ( + "encoding/json" + "io" +) + // Block is the basic data unit. type Block struct { ID string `json:"id"` @@ -12,3 +17,9 @@ type Block struct { UpdateAt int64 `json:"updateAt"` DeleteAt int64 `json:"deleteAt"` } + +func BlocksFromJSON(data io.Reader) []Block { + var blocks []Block + json.NewDecoder(data).Decode(&blocks) + return blocks +} diff --git a/server/server/server.go b/server/server/server.go index b20df2cbc..c5dbe754d 100644 --- a/server/server/server.go +++ b/server/server/server.go @@ -135,5 +135,13 @@ func (s *Server) Start() error { } func (s *Server) Shutdown() error { + if err := s.webServer.Shutdown(); err != nil { + return err + } + return s.store.Shutdown() } + +func (s *Server) Config() *config.Configuration { + return s.config +} diff --git a/server/utils/utils.go b/server/utils/utils.go new file mode 100644 index 000000000..a4018204c --- /dev/null +++ b/server/utils/utils.go @@ -0,0 +1,19 @@ +package utils + +import ( + "crypto/rand" + "fmt" + "log" +) + +// CreateGUID returns a random GUID. +func CreateGUID() string { + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + log.Fatal(err) + } + uuid := fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) + + return uuid +} diff --git a/server/web/webserver.go b/server/web/webserver.go index cc020c8a6..b0f4b9f8f 100644 --- a/server/web/webserver.go +++ b/server/web/webserver.go @@ -20,6 +20,8 @@ type RoutedService interface { // Server is the structure responsible for managing our http web server. type Server struct { + http.Server + router *mux.Router rootPath string port int @@ -31,7 +33,10 @@ func NewServer(rootPath string, port int, ssl bool) *Server { r := mux.NewRouter() ws := &Server{ - router: r, + Server: http.Server{ + Addr: fmt.Sprintf(`:%d`, port), + Handler: r, + }, rootPath: rootPath, port: port, ssl: ssl, @@ -40,14 +45,18 @@ func NewServer(rootPath string, port int, ssl bool) *Server { return ws } +func (ws *Server) Router() *mux.Router { + return ws.Server.Handler.(*mux.Router) +} + // AddRoutes allows services to register themself in the webserver router and provide new endpoints. func (ws *Server) AddRoutes(rs RoutedService) { - rs.RegisterRoutes(ws.router) + rs.RegisterRoutes(ws.Router()) } func (ws *Server) registerRoutes() { - ws.router.PathPrefix("/static").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(ws.rootPath, "static"))))) - ws.router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ws.Router().PathPrefix("/static").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(ws.rootPath, "static"))))) + ws.Router().PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") http.ServeFile(w, r, path.Join(ws.rootPath, "index.html")) }) @@ -56,14 +65,11 @@ func (ws *Server) registerRoutes() { // Start runs the web server and start listening for charsetnnections. func (ws *Server) Start() error { ws.registerRoutes() - http.Handle("/", ws.router) - urlPort := fmt.Sprintf(`:%d`, ws.port) isSSL := ws.ssl && fileExists("./cert/cert.pem") && fileExists("./cert/key.pem") - if isSSL { - log.Println("https server started on ", urlPort) - err := http.ListenAndServeTLS(urlPort, "./cert/cert.pem", "./cert/key.pem", nil) + log.Printf("https server started on :%d\n", ws.port) + err := ws.ListenAndServeTLS("./cert/cert.pem", "./cert/key.pem") if err != nil { return err } @@ -71,8 +77,8 @@ func (ws *Server) Start() error { return nil } - log.Println("http server started on ", urlPort) - err := http.ListenAndServe(urlPort, nil) + log.Println("http server started on :%d\n", ws.port) + err := ws.ListenAndServe() if err != nil { return err } @@ -80,6 +86,10 @@ func (ws *Server) Start() error { return nil } +func (ws *Server) Shutdown() error { + return ws.Close() +} + // fileExists returns true if a file exists at the path. func fileExists(path string) bool { _, err := os.Stat(path) From 27ba0cc6c516cb0f5f94da8b5a9395364db8f51c Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Mon, 9 Nov 2020 18:23:41 +0100 Subject: [PATCH 13/61] Remove unused router from the `web.Server` struct --- server/web/webserver.go | 1 - 1 file changed, 1 deletion(-) diff --git a/server/web/webserver.go b/server/web/webserver.go index b0f4b9f8f..613723019 100644 --- a/server/web/webserver.go +++ b/server/web/webserver.go @@ -22,7 +22,6 @@ type RoutedService interface { type Server struct { http.Server - router *mux.Router rootPath string port int ssl bool From 16e21c74acbcee97a8ab10490e439a006a9555ed Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 9 Nov 2020 10:02:09 -0800 Subject: [PATCH 14/61] Cleanup card sort logic --- webapp/src/viewModel/boardTree.ts | 90 +++++++++++++------------------ 1 file changed, 36 insertions(+), 54 deletions(-) diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index 23452b79d..a3d7759e6 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -241,27 +241,34 @@ class MutableBoardTree implements BoardTree { return CardFilter.applyFilterGroup(filterGroup, board.cardProperties, cards) } - private defaultOrder(cardA: Card, cardB: Card) { + private titleOrCreatedOrder(cardA: Card, cardB: Card) { + const aValue = cardA.title || '' + const bValue = cardB.title || '' + + if (aValue && bValue) { + return aValue.localeCompare(bValue) + } + + // Always put untitled cards at the bottom + if (aValue && !bValue) { + return -1 + } + if (bValue && !aValue) { + return 1 + } + + // If both cards are untitled, use the create date + return cardA.createAt - cardB.createAt + } + + private manualOrder(cardA: Card, cardB: Card) { const {activeView} = this const indexA = activeView.cardOrder.indexOf(cardA.id) const indexB = activeView.cardOrder.indexOf(cardB.id) if (indexA < 0 && indexB < 0) { - // If both cards' order is not defined, first use the title - const aValue = cardA.title || '' - const bValue = cardB.title || '' - - // Always put untitled cards at the bottom - if (aValue && !bValue) { - return -1 - } - if (bValue && !aValue) { - return 1 - } - - // If both cards are untitled, use the create date - return cardA.createAt - cardB.createAt + return this.titleOrCreatedOrder(cardA, cardB) } else if (indexA < 0 && indexB >= 0) { // If cardA's order is not defined, put it at the end return 1 @@ -279,33 +286,14 @@ class MutableBoardTree implements BoardTree { let sortedCards: Card[] = [] if (sortOptions.length < 1) { - Utils.log('Default sort') - sortedCards = cards.sort((a, b) => this.defaultOrder(a, b)) + Utils.log('Manual sort') + sortedCards = cards.sort((a, b) => this.manualOrder(a, b)) } else { sortOptions.forEach((sortOption) => { if (sortOption.propertyId === Constants.titleColumnId) { - Utils.log('Sort by name') + Utils.log('Sort by title') sortedCards = cards.sort((a, b) => { - const aValue = a.title || '' - const bValue = b.title || '' - - // Always put empty values at the bottom, newest last - if (aValue && !bValue) { - return -1 - } - if (bValue && !aValue) { - return 1 - } - if (!aValue && !bValue) { - return this.defaultOrder(a, b) - } - - let result = aValue.localeCompare(bValue) - - if (result === 0) { - // In case of "ties", use the default order - result = this.defaultOrder(a, b) - } + let result = this.titleOrCreatedOrder(a, b) if (sortOption.reversed) { result = -result @@ -319,17 +307,11 @@ class MutableBoardTree implements BoardTree { Utils.logError(`Missing template for property id: ${sortPropertyId}`) return cards.slice() } - Utils.log(`Sort by ${template?.name}`) + Utils.log(`Sort by property: ${template?.name}`) sortedCards = cards.sort((a, b) => { - // Always put cards with no titles at the bottom - if (a.title && !b.title) { - return -1 - } - if (b.title && !a.title) { - return 1 - } - if (!a.title && !b.title) { - return this.defaultOrder(a, b) + // Always put cards with no titles at the bottom, regardless of sort + if (!a.title || !b.title) { + return this.titleOrCreatedOrder(a, b) } const aValue = a.properties[sortPropertyId] || '' @@ -344,7 +326,7 @@ class MutableBoardTree implements BoardTree { return 1 } if (!aValue && !bValue) { - return this.defaultOrder(a, b) + return this.titleOrCreatedOrder(a, b) } // Sort by the option order (not alphabetically by value) @@ -361,12 +343,12 @@ class MutableBoardTree implements BoardTree { return 1 } if (!aValue && !bValue) { - return this.defaultOrder(a, b) + return this.titleOrCreatedOrder(a, b) } result = Number(aValue) - Number(bValue) } else if (template.type === 'createdTime') { - result = this.defaultOrder(a, b) + result = a.createAt - b.createAt } else if (template.type === 'updatedTime') { result = a.updateAt - b.updateAt } else { @@ -380,15 +362,15 @@ class MutableBoardTree implements BoardTree { return 1 } if (!aValue && !bValue) { - return this.defaultOrder(a, b) + return this.titleOrCreatedOrder(a, b) } result = aValue.localeCompare(bValue) } if (result === 0) { - // In case of "ties", use the default order - result = this.defaultOrder(a, b) + // In case of "ties", use the title order + result = this.titleOrCreatedOrder(a, b) } if (sortOption.reversed) { From 4079c99666a43008903ba45cf16cc6d414c4afa1 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 9 Nov 2020 10:02:54 -0800 Subject: [PATCH 15/61] Cleanup drag and drop logic --- webapp/src/components/boardCard.tsx | 8 ++++++-- webapp/src/components/boardColumn.tsx | 14 ++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/webapp/src/components/boardCard.tsx b/webapp/src/components/boardCard.tsx index f16cb0965..56871865f 100644 --- a/webapp/src/components/boardCard.tsx +++ b/webapp/src/components/boardCard.tsx @@ -70,10 +70,14 @@ class BoardCard extends React.Component { }} onDragOver={(e) => { - this.setState({isDragOver: true}) + if (!this.state.isDragOver) { + this.setState({isDragOver: true}) + } }} onDragEnter={(e) => { - this.setState({isDragOver: true}) + if (!this.state.isDragOver) { + this.setState({isDragOver: true}) + } }} onDragLeave={(e) => { this.setState({isDragOver: false}) diff --git a/webapp/src/components/boardColumn.tsx b/webapp/src/components/boardColumn.tsx index e0245878e..84204c423 100644 --- a/webapp/src/components/boardColumn.tsx +++ b/webapp/src/components/boardColumn.tsx @@ -9,8 +9,6 @@ type Props = { type State = { isDragOver?: boolean - dragPageX?: number - dragPageY?: number } class BoardColumn extends React.Component { @@ -29,18 +27,22 @@ class BoardColumn extends React.Component { className={className} onDragOver={(e) => { e.preventDefault() - this.setState({isDragOver: true, dragPageX: e.pageX, dragPageY: e.pageY}) + if (!this.state.isDragOver) { + this.setState({isDragOver: true}) + } }} onDragEnter={(e) => { e.preventDefault() - this.setState({isDragOver: true, dragPageX: e.pageX, dragPageY: e.pageY}) + if (!this.state.isDragOver) { + this.setState({isDragOver: true}) + } }} onDragLeave={(e) => { e.preventDefault() - this.setState({isDragOver: false, dragPageX: undefined, dragPageY: undefined}) + this.setState({isDragOver: false}) }} onDrop={(e) => { - this.setState({isDragOver: false, dragPageX: undefined, dragPageY: undefined}) + this.setState({isDragOver: false}) if (this.props.isDropZone) { this.props.onDrop(e) } From 7ec73468c40a9c5c17aab7d63dbe4f803e2ac382 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 9 Nov 2020 10:04:07 -0800 Subject: [PATCH 16/61] Make BoardPage state immutable --- webapp/src/pages/boardPage.tsx | 29 ++++++++++++++++++++--------- webapp/src/widgets/propertyMenu.tsx | 4 ---- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index cc89bed7e..7a6cb6e14 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -3,13 +3,13 @@ import React from 'react' import {BoardView} from '../blocks/boardView' -import {MutableBoardTree} from '../viewModel/boardTree' +import {BoardTree, MutableBoardTree} from '../viewModel/boardTree' import {WorkspaceComponent} from '../components/workspaceComponent' import {sendFlashMessage} from '../components/flashMessages' import mutator from '../mutator' import {OctoListener} from '../octoListener' import {Utils} from '../utils' -import {MutableWorkspaceTree} from '../viewModel/workspaceTree' +import {MutableWorkspaceTree, WorkspaceTree} from '../viewModel/workspaceTree' import {IBlock} from '../blocks/block' type Props = { @@ -19,8 +19,8 @@ type Props = { type State = { boardId: string viewId: string - workspaceTree: MutableWorkspaceTree - boardTree?: MutableBoardTree + workspaceTree: WorkspaceTree + boardTree?: BoardTree } export default class BoardPage extends React.Component { @@ -145,9 +145,9 @@ export default class BoardPage extends React.Component { } private async sync(boardId: string = this.state.boardId, viewId: string | undefined = this.state.viewId) { - const {workspaceTree} = this.state Utils.log(`sync start: ${boardId}`) + const workspaceTree = new MutableWorkspaceTree() await workspaceTree.sync() const boardIds = workspaceTree.boards.map((o) => o.id) @@ -176,6 +176,7 @@ export default class BoardPage extends React.Component { // TODO: Handle error (viewId not found) this.setState({ + workspaceTree, boardTree, boardId, viewId: boardTree.activeView.id, @@ -220,9 +221,14 @@ export default class BoardPage extends React.Component { } showView(viewId: string, boardId: string = this.state.boardId): void { + if (!this.state.boardTree) { + return + } + if (this.state.boardId === boardId) { - this.state.boardTree.setActiveView(viewId) - this.setState({...this.state, viewId}) + const newBoardTree = this.state.boardTree.mutableCopy() + newBoardTree.setActiveView(viewId) + this.setState({boardTree: newBoardTree, viewId}) } else { this.attachToBoard(boardId, viewId) } @@ -232,7 +238,12 @@ export default class BoardPage extends React.Component { } setSearchText(text?: string): void { - this.state.boardTree?.setSearchText(text) - this.setState({...this.state, boardTree: this.state.boardTree}) + if (!this.state.boardTree) { + return + } + + const newBoardTree = this.state.boardTree.mutableCopy() + newBoardTree.setSearchText(text) + this.setState({boardTree: newBoardTree}) } } diff --git a/webapp/src/widgets/propertyMenu.tsx b/webapp/src/widgets/propertyMenu.tsx index f405dfedd..d15395ee8 100644 --- a/webapp/src/widgets/propertyMenu.tsx +++ b/webapp/src/widgets/propertyMenu.tsx @@ -25,10 +25,6 @@ type State = { export default class PropertyMenu extends React.PureComponent { private nameTextbox = React.createRef() - public shouldComponentUpdate(): boolean { - return true - } - constructor(props: Props) { super(props) this.state = {name: this.props.propertyName} From 1a3cb5a5fb1e39cf4009c92c0677ebbb59588834 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 9 Nov 2020 10:25:00 -0800 Subject: [PATCH 17/61] Test helper: distribute cards --- webapp/src/components/viewHeader.tsx | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/webapp/src/components/viewHeader.tsx b/webapp/src/components/viewHeader.tsx index e2919c4f0..f4f78f1f6 100644 --- a/webapp/src/components/viewHeader.tsx +++ b/webapp/src/components/viewHeader.tsx @@ -112,6 +112,29 @@ class ViewHeader extends React.Component { }) } + private async testDistributeCards() { + const {boardTree} = this.props + if (!boardTree) { + return + } + + await mutator.performAsUndoGroup(async () => { + let optionIndex = 0 + for (const card of boardTree.cards) { + if (boardTree.groupByProperty && boardTree.groupByProperty.options.length > 0) { + // Cycle through options + const option = boardTree.groupByProperty.options[optionIndex] + optionIndex = (optionIndex + 1) % boardTree.groupByProperty.options.length + const newCard = new MutableCard(card) + if (newCard.properties[boardTree.groupByProperty.id] !== option.id) { + newCard.properties[boardTree.groupByProperty.id] = option.id + await mutator.updateBlock(newCard, card, 'test distribute cards') + } + } + } + }) + } + private async testRandomizeIcons() { const {boardTree} = this.props @@ -323,6 +346,9 @@ class ViewHeader extends React.Component { name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export Board Archive'})} onClick={() => Archiver.exportBoardTree(boardTree)} /> + + + { name={intl.formatMessage({id: 'ViewHeader.test-add-1000-cards', defaultMessage: 'TEST: Add 1,000 cards'})} onClick={() => this.testAddCards(1000)} /> + this.testDistributeCards()} + /> Date: Mon, 9 Nov 2020 12:05:55 -0800 Subject: [PATCH 18/61] Fix CardDetail state updates --- webapp/src/components/cardDetail.tsx | 36 ++++++++++++++++++---------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index 5073482be..5f303ae53 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -54,31 +54,39 @@ class CardDetail extends React.Component { } componentDidMount() { + this.createCardTreeAndSync() + } + + private async createCardTreeAndSync() { const cardTree = new MutableCardTree(this.props.cardId) + await cardTree.sync() + this.createListener() + this.setState({cardTree, title: cardTree.card.title}) + setTimeout(() => { + if (this.titleRef.current) { + this.titleRef.current.focus() + } + }, 0) + } + + private createListener() { this.cardListener = new OctoListener() this.cardListener.open( [this.props.cardId], async (blocks) => { Utils.log(`cardListener.onChanged: ${blocks.length}`) - const newCardTree = cardTree.mutableCopy() + const newCardTree = this.state.cardTree.mutableCopy() if (newCardTree.incrementalUpdate(blocks)) { this.setState({cardTree: newCardTree, title: newCardTree.card.title}) } }, async () => { Utils.log('cardListener.onReconnect') - const newCardTree = cardTree.mutableCopy() + const newCardTree = this.state.cardTree.mutableCopy() await newCardTree.sync() this.setState({cardTree: newCardTree, title: newCardTree.card.title}) - }) - cardTree.sync().then(() => { - this.setState({cardTree, title: cardTree.card.title}) - setTimeout(() => { - if (this.titleRef.current) { - this.titleRef.current.focus() - } - }, 0) - }) + } + ) } componentWillUnmount() { @@ -160,7 +168,11 @@ class CardDetail extends React.Component { value={this.state.title} placeholderText='Untitled' onChange={(title: string) => this.setState({title})} - onSave={() => mutator.changeTitle(card, this.state.title)} + onSave={() => { + if (this.state.title !== this.state.cardTree.card.title) { + mutator.changeTitle(card, this.state.title) + } + }} onCancel={() => this.setState({title: this.state.cardTree.card.title})} /> From 48e4cfd1021a6a354c06f500df8b4005b3b66f6f Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 9 Nov 2020 12:53:25 -0800 Subject: [PATCH 19/61] Minor: log.Printf --- server/web/webserver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/web/webserver.go b/server/web/webserver.go index 613723019..d89f620d6 100644 --- a/server/web/webserver.go +++ b/server/web/webserver.go @@ -76,7 +76,7 @@ func (ws *Server) Start() error { return nil } - log.Println("http server started on :%d\n", ws.port) + log.Printf("http server started on :%d\n", ws.port) err := ws.ListenAndServe() if err != nil { return err From 4524a3713ed7d625c9e52e3204bb1e3d0d8e8366 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 9 Nov 2020 13:06:39 -0800 Subject: [PATCH 20/61] Refactor ContentBlock, fix order logic. --- webapp/src/components/cardDetail.tsx | 8 +++---- webapp/src/components/contentBlock.tsx | 32 ++++++++++++-------------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index 5f303ae53..298868d16 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -112,7 +112,7 @@ class CardDetail extends React.Component { key={block.id} block={block} cardId={card.id} - cardTree={cardTree} + contents={cardTree.contents} /> ))} ) @@ -128,7 +128,7 @@ class CardDetail extends React.Component { const block = new MutableTextBlock() block.parentId = card.id block.title = text - block.order = cardTree.contents.length * 1000 + block.order = (this.state.cardTree.contents.length + 1) * 1000 mutator.insertBlock(block, 'add card text') } }} @@ -253,7 +253,7 @@ class CardDetail extends React.Component { onClick={() => { const block = new MutableTextBlock() block.parentId = card.id - block.order = cardTree.contents.length * 1000 + block.order = (this.state.cardTree.contents.length + 1) * 1000 mutator.insertBlock(block, 'add text') }} /> @@ -261,7 +261,7 @@ class CardDetail extends React.Component { id='image' name={intl.formatMessage({id: 'CardDetail.image', defaultMessage: 'Image'})} onClick={() => Utils.selectLocalFile( - (file) => mutator.createImageBlock(card.id, file, cardTree.contents.length * 1000), + (file) => mutator.createImageBlock(card.id, file, (this.state.cardTree.contents.length + 1) * 1000), '.jpg,.jpeg,.png', )} /> diff --git a/webapp/src/components/contentBlock.tsx b/webapp/src/components/contentBlock.tsx index 70bd811c6..ae9ad7897 100644 --- a/webapp/src/components/contentBlock.tsx +++ b/webapp/src/components/contentBlock.tsx @@ -4,7 +4,6 @@ import React from 'react' import {IOrderedBlock} from '../blocks/orderedBlock' -import {CardTree} from '../viewModel/cardTree' import {OctoUtils} from '../octoUtils' import mutator from '../mutator' import {Utils} from '../utils' @@ -30,20 +29,19 @@ import './contentBlock.scss' type Props = { block: IOrderedBlock cardId: string - cardTree: CardTree + contents: readonly IOrderedBlock[] } -class ContentBlock extends React.Component { - shouldComponentUpdate(): boolean { - return true - } - +class ContentBlock extends React.PureComponent { public render(): JSX.Element { - const {cardId, cardTree, block} = this.props + const {cardId, contents, block} = this.props + if (block.type !== 'text' && block.type !== 'image' && block.type !== 'divider') { + Utils.assertFailure(`Block type is unknown: ${block.type}`) return null } - const index = cardTree.contents.indexOf(block) + + const index = contents.indexOf(block) return (
@@ -56,20 +54,20 @@ class ContentBlock extends React.Component { name='Move up' icon={} onClick={() => { - const previousBlock = cardTree.contents[index - 1] - const newOrder = OctoUtils.getOrderBefore(previousBlock, cardTree.contents) + const previousBlock = contents[index - 1] + const newOrder = OctoUtils.getOrderBefore(previousBlock, contents) Utils.log(`moveUp ${newOrder}`) mutator.changeOrder(block, newOrder, 'move up') }} />} - {index < (cardTree.contents.length - 1) && + {index < (contents.length - 1) && } onClick={() => { - const nextBlock = cardTree.contents[index + 1] - const newOrder = OctoUtils.getOrderAfter(nextBlock, cardTree.contents) + const nextBlock = contents[index + 1] + const newOrder = OctoUtils.getOrderAfter(nextBlock, contents) Utils.log(`moveDown ${newOrder}`) mutator.changeOrder(block, newOrder, 'move down') }} @@ -88,7 +86,7 @@ class ContentBlock extends React.Component { newBlock.parentId = cardId // TODO: Handle need to reorder all blocks - newBlock.order = OctoUtils.getOrderBefore(block, cardTree.contents) + newBlock.order = OctoUtils.getOrderBefore(block, contents) Utils.log(`insert block ${block.id}, order: ${block.order}`) mutator.insertBlock(newBlock, 'insert card text') }} @@ -100,7 +98,7 @@ class ContentBlock extends React.Component { onClick={() => { Utils.selectLocalFile( (file) => { - mutator.createImageBlock(cardId, file, OctoUtils.getOrderBefore(block, cardTree.contents)) + mutator.createImageBlock(cardId, file, OctoUtils.getOrderBefore(block, contents)) }, '.jpg,.jpeg,.png') }} @@ -114,7 +112,7 @@ class ContentBlock extends React.Component { newBlock.parentId = cardId // TODO: Handle need to reorder all blocks - newBlock.order = OctoUtils.getOrderBefore(block, cardTree.contents) + newBlock.order = OctoUtils.getOrderBefore(block, contents) Utils.log(`insert block ${block.id}, order: ${block.order}`) mutator.insertBlock(newBlock, 'insert card text') }} From 506f2cd3bf5539087a8ce0b3957bf23fcee57688 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Tue, 10 Nov 2020 10:09:08 -0800 Subject: [PATCH 21/61] Fix empty sidebar on fitst load --- webapp/src/components/cardDetail.tsx | 2 +- webapp/src/components/viewHeader.tsx | 2 +- webapp/src/pages/boardPage.tsx | 15 +++------------ 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index 298868d16..93fe44433 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -85,7 +85,7 @@ class CardDetail extends React.Component { const newCardTree = this.state.cardTree.mutableCopy() await newCardTree.sync() this.setState({cardTree: newCardTree, title: newCardTree.card.title}) - } + }, ) } diff --git a/webapp/src/components/viewHeader.tsx b/webapp/src/components/viewHeader.tsx index f4f78f1f6..8ae50ef02 100644 --- a/webapp/src/components/viewHeader.tsx +++ b/webapp/src/components/viewHeader.tsx @@ -347,7 +347,7 @@ class ViewHeader extends React.Component { onClick={() => Archiver.exportBoardTree(boardTree)} /> - + { const workspaceTree = new MutableWorkspaceTree() await workspaceTree.sync() const boardIds = workspaceTree.boards.map((o) => o.id) + this.setState({workspaceTree}) // Listen to boards plus all blocks at root (Empty string for parentId) this.workspaceListener.open( @@ -161,7 +162,8 @@ export default class BoardPage extends React.Component { () => { Utils.log('workspaceListener.onReconnect') this.sync() - }) + }, + ) if (boardId) { const boardTree = new MutableBoardTree(boardId) @@ -176,14 +178,11 @@ export default class BoardPage extends React.Component { // TODO: Handle error (viewId not found) this.setState({ - workspaceTree, boardTree, boardId, viewId: boardTree.activeView.id, }) Utils.log(`sync complete: ${boardTree.board.id} (${boardTree.board.title})`) - } else { - this.forceUpdate() } } @@ -221,10 +220,6 @@ export default class BoardPage extends React.Component { } showView(viewId: string, boardId: string = this.state.boardId): void { - if (!this.state.boardTree) { - return - } - if (this.state.boardId === boardId) { const newBoardTree = this.state.boardTree.mutableCopy() newBoardTree.setActiveView(viewId) @@ -238,10 +233,6 @@ export default class BoardPage extends React.Component { } setSearchText(text?: string): void { - if (!this.state.boardTree) { - return - } - const newBoardTree = this.state.boardTree.mutableCopy() newBoardTree.setSearchText(text) this.setState({boardTree: newBoardTree}) From 5b07bee7ecca317b16ab3dc9db44745bd3b0f762 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Tue, 10 Nov 2020 11:20:43 -0800 Subject: [PATCH 22/61] Add BlockTypes type --- webapp/src/blocks/block.ts | 11 ++++++----- webapp/src/components/boardCard.tsx | 6 +++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/webapp/src/blocks/block.ts b/webapp/src/blocks/block.ts index 2d360799d..0b4b2a5fe 100644 --- a/webapp/src/blocks/block.ts +++ b/webapp/src/blocks/block.ts @@ -2,12 +2,14 @@ // See LICENSE.txt for license information. import {Utils} from '../utils' +type BlockTypes = 'board' | 'view' | 'card' | 'text' | 'image' | 'divider' | 'comment' + interface IBlock { readonly id: string readonly parentId: string readonly schema: number - readonly type: string + readonly type: BlockTypes readonly title?: string readonly fields: Readonly> @@ -21,7 +23,7 @@ interface IMutableBlock extends IBlock { parentId: string schema: number - type: string + type: BlockTypes title?: string fields: Record @@ -34,19 +36,18 @@ class MutableBlock implements IMutableBlock { id: string = Utils.createGuid() schema: number parentId: string - type: string + type: BlockTypes title: string fields: Record = {} createAt: number = Date.now() updateAt = 0 deleteAt = 0 - static duplicate(block: IBlock): IBlock { + static duplicate(block: IBlock): IMutableBlock { const now = Date.now() const newBlock = new MutableBlock(block) newBlock.id = Utils.createGuid() - newBlock.title = `Copy of ${block.title}` newBlock.createAt = now newBlock.updateAt = now newBlock.deleteAt = 0 diff --git a/webapp/src/components/boardCard.tsx b/webapp/src/components/boardCard.tsx index 56871865f..cf550f3d9 100644 --- a/webapp/src/components/boardCard.tsx +++ b/webapp/src/components/boardCard.tsx @@ -105,7 +105,11 @@ class BoardCard extends React.Component { icon={} id='duplicate' name={intl.formatMessage({id: 'BoardCard.duplicate', defaultMessage: 'Duplicate'})} - onClick={() => mutator.insertBlock(MutableBlock.duplicate(card), 'duplicate card')} + onClick={() => { + const newCard = MutableBlock.duplicate(card) + newCard.title = `Copy of ${card.title}` + mutator.insertBlock(newCard, 'duplicate card') + }} /> From 289f8f9d309555b9c3c5947879af0b10a99fe8fb Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Tue, 10 Nov 2020 11:23:08 -0800 Subject: [PATCH 23/61] Card templates --- webapp/src/blocks/card.ts | 18 ++++++ webapp/src/components/boardComponent.tsx | 75 +++++++++++++++++++++--- webapp/src/components/cardDialog.tsx | 10 ++++ webapp/src/components/dialog.scss | 5 ++ webapp/src/components/tableComponent.tsx | 60 +++++++++++++++++-- webapp/src/components/viewHeader.tsx | 65 +++++++++++++++++--- webapp/src/viewModel/boardTree.ts | 7 ++- webapp/src/viewModel/cardTree.ts | 22 +++++-- webapp/src/widgets/menu/menu.scss | 6 ++ 9 files changed, 241 insertions(+), 27 deletions(-) diff --git a/webapp/src/blocks/card.ts b/webapp/src/blocks/card.ts index 815b1aaa2..bde93fd6a 100644 --- a/webapp/src/blocks/card.ts +++ b/webapp/src/blocks/card.ts @@ -1,12 +1,15 @@ // 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' interface Card extends IBlock { readonly icon: string + readonly isTemplate: boolean readonly properties: Readonly> + newCardFromTemplate(): MutableCard } class MutableCard extends MutableBlock { @@ -17,6 +20,13 @@ class MutableCard extends MutableBlock { this.fields.icon = value } + get isTemplate(): boolean { + return this.fields.isTemplate as boolean + } + set isTemplate(value: boolean) { + this.fields.isTemplate = value + } + get properties(): Record { return this.fields.properties as Record } @@ -30,6 +40,14 @@ class MutableCard extends MutableBlock { this.properties = {...(block.fields?.properties || {})} } + + newCardFromTemplate(): MutableCard { + const card = new MutableCard(this) + card.id = Utils.createGuid() + card.isTemplate = false + card.title = '' + return card + } } export {MutableCard, Card} diff --git a/webapp/src/components/boardComponent.tsx b/webapp/src/components/boardComponent.tsx index 579fa78c9..38b738724 100644 --- a/webapp/src/components/boardComponent.tsx +++ b/webapp/src/components/boardComponent.tsx @@ -6,8 +6,10 @@ import {injectIntl, IntlShape, FormattedMessage} from 'react-intl' import {BlockIcons} from '../blockIcons' import {IPropertyOption, IPropertyTemplate} from '../blocks/board' +import {IBlock} from '../blocks/block' import {Card, MutableCard} from '../blocks/card' import {BoardTree, BoardTreeGroup} from '../viewModel/boardTree' +import {MutableCardTree} from '../viewModel/cardTree' import {CardFilter} from '../cardFilter' import {Constants} from '../constants' import mutator from '../mutator' @@ -150,6 +152,10 @@ class BoardComponent extends React.Component { showView={showView} setSearchText={this.props.setSearchText} addCard={() => this.addCard()} + addCardFromTemplate={this.addCardFromTemplate} + addCardTemplate={() => this.addCardTemplate()} + editCardTemplate={this.editCardTemplate} + deleteCardTemplate={this.deleteCardTemplate} withGroupBy={true} />
{ } } - private async addCard(groupByOptionId?: string): Promise { + private addCardFromTemplate = async (cardTemplate?: Card) => { + this.addCard(undefined, cardTemplate) + } + + private async addCard(groupByOptionId?: string, cardTemplate?: Card): Promise { const {boardTree} = this.props const {activeView, board} = boardTree - const card = new MutableCard() + let card: MutableCard + let blocksToInsert: IBlock[] + if (cardTemplate) { + const templateCardTree = new MutableCardTree(cardTemplate.id) + await templateCardTree.sync() + const newCardTree = templateCardTree.duplicateFromTemplate() + card = newCardTree.card + blocksToInsert = [newCardTree.card, ...newCardTree.contents] + } else { + card = new MutableCard() + blocksToInsert = [card] + } + card.parentId = boardTree.board.id - card.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties) - card.icon = BlockIcons.shared.randomIcon() + const propertiesThatMeetFilters = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties) if (boardTree.groupByProperty) { if (groupByOptionId) { - card.properties[boardTree.groupByProperty.id] = groupByOptionId + propertiesThatMeetFilters[boardTree.groupByProperty.id] = groupByOptionId } else { - delete card.properties[boardTree.groupByProperty.id] + delete propertiesThatMeetFilters[boardTree.groupByProperty.id] } } - await mutator.insertBlock(card, 'add card', async () => { - this.setState({shownCard: card}) + card.properties = {...card.properties, ...propertiesThatMeetFilters} + card.icon = BlockIcons.shared.randomIcon() + await mutator.insertBlocks( + blocksToInsert, + 'add card', + async () => { + this.setState({shownCard: card}) + }, + async () => { + this.setState({shownCard: undefined}) + }, + ) + } + + private async addCardTemplate(groupByOptionId?: string): Promise { + const {boardTree} = this.props + const {activeView, board} = boardTree + + const cardTemplate = new MutableCard() + cardTemplate.isTemplate = true + cardTemplate.parentId = boardTree.board.id + cardTemplate.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties) + if (boardTree.groupByProperty) { + if (groupByOptionId) { + cardTemplate.properties[boardTree.groupByProperty.id] = groupByOptionId + } else { + delete cardTemplate.properties[boardTree.groupByProperty.id] + } + } + await mutator.insertBlock(cardTemplate, 'add card template', async () => { + this.setState({shownCard: cardTemplate}) }, async () => { this.setState({shownCard: undefined}) }) } + private editCardTemplate = (cardTemplate: Card) => { + this.setState({shownCard: cardTemplate}) + } + + private deleteCardTemplate = (cardTemplate: Card) => { + mutator.deleteBlock(cardTemplate, 'delete card template') + } + private async propertyNameChanged(option: IPropertyOption, text: string): Promise { const {boardTree} = this.props @@ -554,7 +612,6 @@ class BoardComponent extends React.Component { const {draggedCards, draggedHeaderOption} = this const optionId = option ? option.id : undefined - Utils.assertValue(mutator) Utils.assertValue(boardTree) if (draggedCards.length > 0) { diff --git a/webapp/src/components/cardDialog.tsx b/webapp/src/components/cardDialog.tsx index 8a5bec0c0..0727c5ccd 100644 --- a/webapp/src/components/cardDialog.tsx +++ b/webapp/src/components/cardDialog.tsx @@ -2,6 +2,8 @@ // See LICENSE.txt for license information. import React from 'react' +import {FormattedMessage} from 'react-intl' + import {Card} from '../blocks/card' import {BoardTree} from '../viewModel/boardTree' import mutator from '../mutator' @@ -37,6 +39,14 @@ class CardDialog extends React.Component { onClose={this.props.onClose} toolsMenu={menu} > + {(this.props.card.isTemplate) && +
+ +
+ } .banner { + background-color: rgba(230, 220, 192, 0.9); + text-align: center; + padding: 10px; + } > .toolbar { display: flex; flex-direction: row; diff --git a/webapp/src/components/tableComponent.tsx b/webapp/src/components/tableComponent.tsx index e7dc35965..70b1a6d78 100644 --- a/webapp/src/components/tableComponent.tsx +++ b/webapp/src/components/tableComponent.tsx @@ -26,6 +26,8 @@ import './tableComponent.scss' import {HorizontalGrip} from './horizontalGrip' import {MutableBoardView} from '../blocks/boardView' +import {IBlock} from '../blocks/block' +import {MutableCardTree} from '../viewModel/cardTree' type Props = { boardTree?: BoardTree @@ -93,7 +95,11 @@ class TableComponent extends React.Component { boardTree={boardTree} showView={showView} setSearchText={this.props.setSearchText} - addCard={this.addCard} + addCard={this.addCardAndShow} + addCardFromTemplate={this.addCardFromTemplate} + addCardTemplate={this.addCardTemplate} + editCardTemplate={this.editCardTemplate} + deleteCardTemplate={this.deleteCardTemplate} /> {/* Main content */} @@ -293,14 +299,34 @@ class TableComponent extends React.Component { return Math.max(Constants.minColumnWidth, this.props.boardTree?.activeView?.columnWidths[templateId] || 0) } - private addCard = async (show = false) => { + private addCardAndShow = () => { + this.addCard(true) + } + + private addCardFromTemplate = async (cardTemplate?: Card) => { + this.addCard(true, cardTemplate) + } + + private addCard = async (show = false, cardTemplate?: Card) => { const {boardTree} = this.props - const card = new MutableCard() + let card: MutableCard + let blocksToInsert: IBlock[] + if (cardTemplate) { + const templateCardTree = new MutableCardTree(cardTemplate.id) + await templateCardTree.sync() + const newCardTree = templateCardTree.duplicateFromTemplate() + card = newCardTree.card + blocksToInsert = [newCardTree.card, ...newCardTree.contents] + } else { + card = new MutableCard() + blocksToInsert = [card] + } + card.parentId = boardTree.board.id card.icon = BlockIcons.shared.randomIcon() - await mutator.insertBlock( - card, + await mutator.insertBlocks( + blocksToInsert, 'add card', async () => { if (show) { @@ -313,6 +339,30 @@ class TableComponent extends React.Component { ) } + private addCardTemplate = async () => { + const {boardTree} = this.props + + const cardTemplate = new MutableCard() + cardTemplate.isTemplate = true + cardTemplate.parentId = boardTree.board.id + cardTemplate.icon = BlockIcons.shared.randomIcon() + await mutator.insertBlock( + cardTemplate, + 'add card', + async () => { + this.setState({shownCard: cardTemplate}) + }, + ) + } + + private editCardTemplate = (cardTemplate: Card) => { + this.setState({shownCard: cardTemplate}) + } + + private deleteCardTemplate = (cardTemplate: Card) => { + mutator.deleteBlock(cardTemplate, 'delete card template') + } + private async onDropToColumn(template: IPropertyTemplate) { const {draggedHeaderTemplate} = this if (!draggedHeaderTemplate) { diff --git a/webapp/src/components/viewHeader.tsx b/webapp/src/components/viewHeader.tsx index 8ae50ef02..6ae598756 100644 --- a/webapp/src/components/viewHeader.tsx +++ b/webapp/src/components/viewHeader.tsx @@ -6,7 +6,7 @@ import {injectIntl, IntlShape, FormattedMessage} from 'react-intl' import {Archiver} from '../archiver' import {ISortOption, MutableBoardView} from '../blocks/boardView' import {BlockIcons} from '../blockIcons' -import {MutableCard} from '../blocks/card' +import {Card, MutableCard} from '../blocks/card' import {IPropertyTemplate} from '../blocks/board' import {BoardTree} from '../viewModel/boardTree' import ViewMenu from '../components/viewMenu' @@ -29,15 +29,19 @@ import {Editable} from './editable' import FilterComponent from './filterComponent' import './viewHeader.scss' -import {sendFlashMessage} from './flashMessages' import {Constants} from '../constants' +import DeleteIcon from '../widgets/icons/delete' type Props = { boardTree?: BoardTree showView: (id: string) => void setSearchText: (text: string) => void - addCard: (show: boolean) => void + addCard: () => void + addCardFromTemplate: (cardTemplate?: Card) => void + addCardTemplate: () => void + editCardTemplate: (cardTemplate: Card) => void + deleteCardTemplate: (cardTemplate: Card) => void withGroupBy?: boolean intl: IntlShape } @@ -371,9 +375,10 @@ class ViewHeader extends React.Component { /> + { - this.props.addCard(true) + this.props.addCard() }} text={( { /> + + + + {boardTree.cardTemplates.map((cardTemplate) => { + return ( + { + this.props.addCardFromTemplate(cardTemplate) + }} + rightIcon={ + + }/> + + { + this.props.editCardTemplate(cardTemplate) + }} + /> + } + id='delete' + name={intl.formatMessage({id: 'ViewHeader.delete-template', defaultMessage: 'Delete'})} + onClick={() => { + this.props.deleteCardTemplate(cardTemplate) + }} + /> + + + } + /> + ) + })} + sendFlashMessage({content: 'Not implemented yet', severity: 'low'})} + id='empty-template' + name={intl.formatMessage({id: 'ViewHeader.empty-card', defaultMessage: 'Empty card'})} + onClick={() => { + this.props.addCard() + }} + /> + + this.props.addCardTemplate()} /> diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index a3d7759e6..274016c17 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -19,6 +19,7 @@ interface BoardTree { readonly board: Board readonly views: readonly BoardView[] readonly cards: readonly Card[] + readonly cardTemplates: readonly Card[] readonly allCards: readonly Card[] readonly visibleGroups: readonly Group[] readonly hiddenGroups: readonly Group[] @@ -37,6 +38,7 @@ class MutableBoardTree implements BoardTree { board!: MutableBoard views: MutableBoardView[] = [] cards: MutableCard[] = [] + cardTemplates: MutableCard[] = [] visibleGroups: Group[] = [] hiddenGroups: Group[] = [] @@ -47,7 +49,7 @@ class MutableBoardTree implements BoardTree { private searchText?: string allCards: MutableCard[] = [] get allBlocks(): IBlock[] { - return [this.board, ...this.views, ...this.allCards] + return [this.board, ...this.views, ...this.allCards, ...this.cardTemplates] } constructor(private boardId: string) { @@ -71,7 +73,8 @@ class MutableBoardTree implements BoardTree { private rebuild(blocks: IMutableBlock[]) { this.board = blocks.find((block) => block.type === 'board') as MutableBoard this.views = blocks.filter((block) => block.type === 'view') as MutableBoardView[] - this.allCards = blocks.filter((block) => block.type === 'card') as MutableCard[] + this.allCards = blocks.filter((block) => block.type === 'card' && !(block as Card).isTemplate) as MutableCard[] + this.cardTemplates = blocks.filter((block) => block.type === 'card' && (block as Card).isTemplate) as MutableCard[] this.cards = [] this.ensureMinimumSchema() diff --git a/webapp/src/viewModel/cardTree.ts b/webapp/src/viewModel/cardTree.ts index 0085236d6..32c77133e 100644 --- a/webapp/src/viewModel/cardTree.ts +++ b/webapp/src/viewModel/cardTree.ts @@ -1,10 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Card} from '../blocks/card' +import {Card, MutableCard} from '../blocks/card' import {IOrderedBlock} from '../blocks/orderedBlock' import octoClient from '../octoClient' -import {IBlock} from '../blocks/block' +import {IBlock, MutableBlock} from '../blocks/block' import {OctoUtils} from '../octoUtils' +import {Utils} from '../utils' interface CardTree { readonly card: Card @@ -15,7 +16,7 @@ interface CardTree { } class MutableCardTree implements CardTree { - card: Card + card: MutableCard comments: IBlock[] = [] contents: IOrderedBlock[] = [] @@ -40,7 +41,7 @@ class MutableCardTree implements CardTree { } private rebuild(blocks: IBlock[]) { - this.card = blocks.find((o) => o.id === this.cardId) as Card + this.card = blocks.find((o) => o.id === this.cardId) as MutableCard this.comments = blocks. filter((block) => block.type === 'comment'). @@ -55,6 +56,19 @@ class MutableCardTree implements CardTree { cardTree.incrementalUpdate(this.rawBlocks) return cardTree } + + duplicateFromTemplate(): MutableCardTree { + const card = this.card.newCardFromTemplate() + const contents: IOrderedBlock[] = this.contents.map((content) => { + const copy = MutableBlock.duplicate(content) + copy.parentId = card.id + return copy as IOrderedBlock + }) + + const cardTree = new MutableCardTree(card.id) + cardTree.incrementalUpdate([card, ...contents]) + return cardTree + } } export {MutableCardTree, CardTree} diff --git a/webapp/src/widgets/menu/menu.scss b/webapp/src/widgets/menu/menu.scss index 2181b768c..4436e94af 100644 --- a/webapp/src/widgets/menu/menu.scss +++ b/webapp/src/widgets/menu/menu.scss @@ -19,6 +19,8 @@ display: flex; flex-direction: column; + flex-grow: 1; + list-style: none; padding: 0; margin: 0; @@ -36,6 +38,10 @@ cursor: pointer; touch-action: none; + * { + display: flex; + } + &:hover { background: rgba(90, 90, 90, 0.1); } From 644523173722337a64fc59c32815b5f492a4384d Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Tue, 10 Nov 2020 11:24:07 -0800 Subject: [PATCH 24/61] Allow nested MenuWrappers to close on item click --- webapp/src/widgets/menu/colorOption.tsx | 3 ++- webapp/src/widgets/menu/switchOption.tsx | 3 ++- webapp/src/widgets/menu/textOption.tsx | 3 ++- webapp/src/widgets/menuWrapper.tsx | 2 ++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/webapp/src/widgets/menu/colorOption.tsx b/webapp/src/widgets/menu/colorOption.tsx index 174cc5880..8b4243072 100644 --- a/webapp/src/widgets/menu/colorOption.tsx +++ b/webapp/src/widgets/menu/colorOption.tsx @@ -11,7 +11,8 @@ type ColorOptionProps = MenuOptionProps & { } export default class ColorOption extends React.PureComponent { - private handleOnClick = (): void => { + private handleOnClick = (e: React.MouseEvent): void => { + e.target.dispatchEvent(new Event('menuItemClicked')) this.props.onClick(this.props.id) } diff --git a/webapp/src/widgets/menu/switchOption.tsx b/webapp/src/widgets/menu/switchOption.tsx index 503cd5f80..405d9098f 100644 --- a/webapp/src/widgets/menu/switchOption.tsx +++ b/webapp/src/widgets/menu/switchOption.tsx @@ -12,7 +12,8 @@ type SwitchOptionProps = MenuOptionProps & { } export default class SwitchOption extends React.PureComponent { - private handleOnClick = (): void => { + private handleOnClick = (e: React.MouseEvent): void => { + e.target.dispatchEvent(new Event('menuItemClicked')) this.props.onClick(this.props.id) } diff --git a/webapp/src/widgets/menu/textOption.tsx b/webapp/src/widgets/menu/textOption.tsx index 9aa0f4fa7..9dd75254c 100644 --- a/webapp/src/widgets/menu/textOption.tsx +++ b/webapp/src/widgets/menu/textOption.tsx @@ -10,7 +10,8 @@ type TextOptionProps = MenuOptionProps & { } export default class TextOption extends React.PureComponent { - private handleOnClick = (): void => { + private handleOnClick = (e: React.MouseEvent): void => { + e.target.dispatchEvent(new Event('menuItemClicked')) this.props.onClick(this.props.id) } diff --git a/webapp/src/widgets/menuWrapper.tsx b/webapp/src/widgets/menuWrapper.tsx index 2fde9c91a..2bd8a8014 100644 --- a/webapp/src/widgets/menuWrapper.tsx +++ b/webapp/src/widgets/menuWrapper.tsx @@ -32,11 +32,13 @@ export default class MenuWrapper extends React.PureComponent { } public componentDidMount() { + document.addEventListener('menuItemClicked', this.close, true) document.addEventListener('click', this.closeOnBlur, true) document.addEventListener('keyup', this.keyboardClose, true) } public componentWillUnmount() { + document.removeEventListener('menuItemClicked', this.close, true) document.removeEventListener('click', this.closeOnBlur, true) document.removeEventListener('keyup', this.keyboardClose, true) } From ca06bfbba1d1f5e0e52d5be00eeee2dd75c6aa00 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Tue, 10 Nov 2020 11:29:01 -0800 Subject: [PATCH 25/61] Menu name margin --- webapp/src/widgets/menu/menu.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/src/widgets/menu/menu.scss b/webapp/src/widgets/menu/menu.scss index 4436e94af..cd6f8a090 100644 --- a/webapp/src/widgets/menu/menu.scss +++ b/webapp/src/widgets/menu/menu.scss @@ -48,6 +48,7 @@ .menu-name { flex-grow: 1; + margin-right: 20px; } .SubmenuTriangleIcon { From ca1c46dbab00c10cf3f7ac89c2e51ff87fdf0ebe Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Tue, 10 Nov 2020 13:19:46 -0800 Subject: [PATCH 26/61] Save board and card title on Esc --- webapp/src/components/cardDetail.tsx | 1 + webapp/src/components/viewTitle.tsx | 1 + webapp/src/widgets/editable.tsx | 13 +++++++------ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index 93fe44433..f62c84cfd 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -168,6 +168,7 @@ class CardDetail extends React.Component { value={this.state.title} placeholderText='Untitled' onChange={(title: string) => this.setState({title})} + saveOnEsc={true} onSave={() => { if (this.state.title !== this.state.cardTree.card.title) { mutator.changeTitle(card, this.state.title) diff --git a/webapp/src/components/viewTitle.tsx b/webapp/src/components/viewTitle.tsx index c992b88b5..bd1fe06f7 100644 --- a/webapp/src/components/viewTitle.tsx +++ b/webapp/src/components/viewTitle.tsx @@ -62,6 +62,7 @@ class ViewTitle extends React.Component { value={this.state.title} placeholderText={intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled Board'})} onChange={(title) => this.setState({title})} + saveOnEsc={true} onSave={() => mutator.changeTitle(board, this.state.title)} onCancel={() => this.setState({title: this.props.board.title})} /> diff --git a/webapp/src/widgets/editable.tsx b/webapp/src/widgets/editable.tsx index 8f038c317..a665776d0 100644 --- a/webapp/src/widgets/editable.tsx +++ b/webapp/src/widgets/editable.tsx @@ -9,9 +9,10 @@ type Props = { value?: string placeholderText?: string className?: string + saveOnEsc?: boolean onCancel?: () => void - onSave?: (saveType: 'onEnter'|'onBlur') => void + onSave?: (saveType: 'onEnter'|'onEsc'|'onBlur') => void } export default class Editable extends React.Component { @@ -52,15 +53,15 @@ export default class Editable extends React.Component { onKeyDown={(e: React.KeyboardEvent): void => { if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC e.stopPropagation() - if (this.props.onCancel) { - this.props.onCancel() + if (this.props.saveOnEsc) { + this.props.onSave?.('onEsc') + } else { + this.props.onCancel?.() } this.blur() } else if (e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return e.stopPropagation() - if (this.props.onSave) { - this.props.onSave('onEnter') - } + this.props.onSave?.('onEnter') this.blur() } }} From 57d7eb35bd130e6ac146cb608754307483df669b Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Wed, 11 Nov 2020 09:21:16 -0800 Subject: [PATCH 27/61] Create template from card --- webapp/src/blocks/card.ts | 6 +- webapp/src/components/boardComponent.tsx | 43 ++++----- webapp/src/components/cardDetail.tsx | 63 ++---------- webapp/src/components/cardDialog.tsx | 116 +++++++++++++++++++++-- webapp/src/components/tableComponent.tsx | 42 ++++---- webapp/src/components/tableRow.tsx | 15 +-- webapp/src/components/viewHeader.tsx | 15 ++- webapp/src/viewModel/cardTree.ts | 6 +- webapp/src/widgets/menu/menu.scss | 1 + 9 files changed, 174 insertions(+), 133 deletions(-) diff --git a/webapp/src/blocks/card.ts b/webapp/src/blocks/card.ts index bde93fd6a..495e9b091 100644 --- a/webapp/src/blocks/card.ts +++ b/webapp/src/blocks/card.ts @@ -9,7 +9,7 @@ interface Card extends IBlock { readonly icon: string readonly isTemplate: boolean readonly properties: Readonly> - newCardFromTemplate(): MutableCard + duplicate(): MutableCard } class MutableCard extends MutableBlock { @@ -41,11 +41,9 @@ class MutableCard extends MutableBlock { this.properties = {...(block.fields?.properties || {})} } - newCardFromTemplate(): MutableCard { + duplicate(): MutableCard { const card = new MutableCard(this) card.id = Utils.createGuid() - card.isTemplate = false - card.title = '' return card } } diff --git a/webapp/src/components/boardComponent.tsx b/webapp/src/components/boardComponent.tsx index 38b738724..d7b8f7bb6 100644 --- a/webapp/src/components/boardComponent.tsx +++ b/webapp/src/components/boardComponent.tsx @@ -43,7 +43,7 @@ type Props = { type State = { isSearching: boolean - shownCard?: Card + shownCardId?: string viewMenu: boolean selectedCardIds: string[] showFilter: boolean @@ -131,12 +131,14 @@ class BoardComponent extends React.Component { this.backgroundClicked(e) }} > - {this.state.shownCard && + {this.state.shownCardId && this.setState({shownCard: undefined})} + cardId={this.state.shownCardId} + onClose={() => this.setState({shownCardId: undefined})} + showCard={(cardId) => this.setState({shownCardId: cardId})} /> } @@ -155,7 +157,6 @@ class BoardComponent extends React.Component { addCardFromTemplate={this.addCardFromTemplate} addCardTemplate={() => this.addCardTemplate()} editCardTemplate={this.editCardTemplate} - deleteCardTemplate={this.deleteCardTemplate} withGroupBy={true} />
{ } } - private addCardFromTemplate = async (cardTemplate?: Card) => { - this.addCard(undefined, cardTemplate) + private addCardFromTemplate = async (cardTemplateId?: string) => { + this.addCard(undefined, cardTemplateId) } - private async addCard(groupByOptionId?: string, cardTemplate?: Card): Promise { + private async addCard(groupByOptionId?: string, cardTemplateId?: string): Promise { const {boardTree} = this.props const {activeView, board} = boardTree let card: MutableCard let blocksToInsert: IBlock[] - if (cardTemplate) { - const templateCardTree = new MutableCardTree(cardTemplate.id) + if (cardTemplateId) { + const templateCardTree = new MutableCardTree(cardTemplateId) await templateCardTree.sync() - const newCardTree = templateCardTree.duplicateFromTemplate() + const newCardTree = templateCardTree.templateCopy() card = newCardTree.card + card.isTemplate = false + card.title = '' blocksToInsert = [newCardTree.card, ...newCardTree.contents] } else { card = new MutableCard() @@ -515,10 +518,10 @@ class BoardComponent extends React.Component { blocksToInsert, 'add card', async () => { - this.setState({shownCard: card}) + this.setState({shownCardId: card.id}) }, async () => { - this.setState({shownCard: undefined}) + this.setState({shownCardId: undefined}) }, ) } @@ -539,18 +542,14 @@ class BoardComponent extends React.Component { } } await mutator.insertBlock(cardTemplate, 'add card template', async () => { - this.setState({shownCard: cardTemplate}) + this.setState({shownCardId: cardTemplate.id}) }, async () => { - this.setState({shownCard: undefined}) + this.setState({shownCardId: undefined}) }) } - private editCardTemplate = (cardTemplate: Card) => { - this.setState({shownCard: cardTemplate}) - } - - private deleteCardTemplate = (cardTemplate: Card) => { - mutator.deleteBlock(cardTemplate, 'delete card template') + private editCardTemplate = (cardTemplateId: string) => { + this.setState({shownCardId: cardTemplateId}) } private async propertyNameChanged(option: IPropertyOption, text: string): Promise { @@ -586,7 +585,7 @@ class BoardComponent extends React.Component { this.setState({selectedCardIds}) } } else { - this.setState({selectedCardIds: [], shownCard: card}) + this.setState({selectedCardIds: [], shownCardId: card.id}) } e.stopPropagation() diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index f62c84cfd..48b3d68d2 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -7,9 +7,8 @@ import {BlockIcons} from '../blockIcons' import {MutableTextBlock} from '../blocks/textBlock' import {BoardTree} from '../viewModel/boardTree' import {PropertyType} from '../blocks/board' -import {CardTree, MutableCardTree} from '../viewModel/cardTree' +import {CardTree} from '../viewModel/cardTree' import mutator from '../mutator' -import {OctoListener} from '../octoListener' import {Utils} from '../utils' import MenuWrapper from '../widgets/menuWrapper' @@ -29,18 +28,16 @@ import './cardDetail.scss' type Props = { boardTree: BoardTree - cardId: string + cardTree: CardTree intl: IntlShape } type State = { - cardTree?: CardTree title: string } class CardDetail extends React.Component { private titleRef = React.createRef() - private cardListener?: OctoListener shouldComponentUpdate() { return true @@ -49,54 +46,12 @@ class CardDetail extends React.Component { constructor(props: Props) { super(props) this.state = { - title: '', + title: props.cardTree.card.title, } } - componentDidMount() { - this.createCardTreeAndSync() - } - - private async createCardTreeAndSync() { - const cardTree = new MutableCardTree(this.props.cardId) - await cardTree.sync() - this.createListener() - this.setState({cardTree, title: cardTree.card.title}) - setTimeout(() => { - if (this.titleRef.current) { - this.titleRef.current.focus() - } - }, 0) - } - - private createListener() { - this.cardListener = new OctoListener() - this.cardListener.open( - [this.props.cardId], - async (blocks) => { - Utils.log(`cardListener.onChanged: ${blocks.length}`) - const newCardTree = this.state.cardTree.mutableCopy() - if (newCardTree.incrementalUpdate(blocks)) { - this.setState({cardTree: newCardTree, title: newCardTree.card.title}) - } - }, - async () => { - Utils.log('cardListener.onReconnect') - const newCardTree = this.state.cardTree.mutableCopy() - await newCardTree.sync() - this.setState({cardTree: newCardTree, title: newCardTree.card.title}) - }, - ) - } - - componentWillUnmount() { - this.cardListener?.close() - this.cardListener = undefined - } - render() { - const {boardTree, intl} = this.props - const {cardTree} = this.state + const {boardTree, cardTree, intl} = this.props const {board} = boardTree if (!cardTree) { return null @@ -128,7 +83,7 @@ class CardDetail extends React.Component { const block = new MutableTextBlock() block.parentId = card.id block.title = text - block.order = (this.state.cardTree.contents.length + 1) * 1000 + block.order = (this.props.cardTree.contents.length + 1) * 1000 mutator.insertBlock(block, 'add card text') } }} @@ -170,11 +125,11 @@ class CardDetail extends React.Component { onChange={(title: string) => this.setState({title})} saveOnEsc={true} onSave={() => { - if (this.state.title !== this.state.cardTree.card.title) { + if (this.state.title !== this.props.cardTree.card.title) { mutator.changeTitle(card, this.state.title) } }} - onCancel={() => this.setState({title: this.state.cardTree.card.title})} + onCancel={() => this.setState({title: this.props.cardTree.card.title})} /> {/* Property list */} @@ -254,7 +209,7 @@ class CardDetail extends React.Component { onClick={() => { const block = new MutableTextBlock() block.parentId = card.id - block.order = (this.state.cardTree.contents.length + 1) * 1000 + block.order = (this.props.cardTree.contents.length + 1) * 1000 mutator.insertBlock(block, 'add text') }} /> @@ -262,7 +217,7 @@ class CardDetail extends React.Component { id='image' name={intl.formatMessage({id: 'CardDetail.image', defaultMessage: 'Image'})} onClick={() => Utils.selectLocalFile( - (file) => mutator.createImageBlock(card.id, file, (this.state.cardTree.contents.length + 1) * 1000), + (file) => mutator.createImageBlock(card.id, file, (this.props.cardTree.contents.length + 1) * 1000), '.jpg,.jpeg,.png', )} /> diff --git a/webapp/src/components/cardDialog.tsx b/webapp/src/components/cardDialog.tsx index 0727c5ccd..27e95bd0b 100644 --- a/webapp/src/components/cardDialog.tsx +++ b/webapp/src/components/cardDialog.tsx @@ -4,23 +4,79 @@ import React from 'react' import {FormattedMessage} from 'react-intl' -import {Card} from '../blocks/card' import {BoardTree} from '../viewModel/boardTree' import mutator from '../mutator' import Menu from '../widgets/menu' import DeleteIcon from '../widgets/icons/delete' -import CardDetail from './cardDetail' +import {MutableCardTree} from '../viewModel/cardTree' +import {CardTree} from '../viewModel/cardTree' +import {OctoListener} from '../octoListener' +import {Utils} from '../utils' + import Dialog from './dialog' +import CardDetail from './cardDetail' type Props = { boardTree: BoardTree - card: Card + cardId: string onClose: () => void + showCard: (cardId?: string) => void } -class CardDialog extends React.Component { +type State = { + cardTree?: CardTree +} + +class CardDialog extends React.Component { + state: State = {} + + private cardListener?: OctoListener + + shouldComponentUpdate() { + return true + } + + componentDidMount() { + this.createCardTreeAndSync() + } + + private async createCardTreeAndSync() { + const cardTree = new MutableCardTree(this.props.cardId) + await cardTree.sync() + this.createListener() + this.setState({cardTree}) + Utils.log(`cardDialog.createCardTreeAndSync: ${cardTree.card.id}`) + } + + private createListener() { + this.cardListener = new OctoListener() + this.cardListener.open( + [this.props.cardId], + async (blocks) => { + Utils.log(`cardListener.onChanged: ${blocks.length}`) + const newCardTree = this.state.cardTree.mutableCopy() + if (newCardTree.incrementalUpdate(blocks)) { + this.setState({cardTree: newCardTree}) + } + }, + async () => { + Utils.log('cardListener.onReconnect') + const newCardTree = this.state.cardTree.mutableCopy() + await newCardTree.sync() + this.setState({cardTree: newCardTree}) + }, + ) + } + + componentWillUnmount() { + this.cardListener?.close() + this.cardListener = undefined + } + render() { + const {cardTree} = this.state + const menu = ( { icon={} name='Delete' onClick={async () => { - await mutator.deleteBlock(this.props.card, 'delete card') + const card = this.state.cardTree?.card + if (!card) { + Utils.assertFailure() + return + } + await mutator.deleteBlock(card, 'delete card') this.props.onClose() }} /> + {(cardTree && !cardTree.card.isTemplate) && + + } ) return ( @@ -39,7 +107,7 @@ class CardDialog extends React.Component { onClose={this.props.onClose} toolsMenu={menu} > - {(this.props.card.isTemplate) && + {(cardTree?.card.isTemplate) &&
{ />
} - + {this.state.cardTree && + + } ) } + + private makeTemplate = 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) + }, + async () => { + this.props.showCard(undefined) + }, + ) + } } export {CardDialog} diff --git a/webapp/src/components/tableComponent.tsx b/webapp/src/components/tableComponent.tsx index 70b1a6d78..e0eb5cdd7 100644 --- a/webapp/src/components/tableComponent.tsx +++ b/webapp/src/components/tableComponent.tsx @@ -6,7 +6,7 @@ import {FormattedMessage} from 'react-intl' import {Constants} from '../constants' import {BlockIcons} from '../blockIcons' import {IPropertyTemplate} from '../blocks/board' -import {Card, MutableCard} from '../blocks/card' +import {MutableCard} from '../blocks/card' import {BoardTree} from '../viewModel/boardTree' import mutator from '../mutator' import {Utils} from '../utils' @@ -36,7 +36,7 @@ type Props = { } type State = { - shownCard?: Card + shownCardId?: string } class TableComponent extends React.Component { @@ -76,12 +76,14 @@ class TableComponent extends React.Component { return (
- {this.state.shownCard && + {this.state.shownCardId && this.setState({shownCard: undefined})} + cardId={this.state.shownCardId} + onClose={() => this.setState({shownCardId: undefined})} + showCard={(cardId) => this.setState({shownCardId: cardId})} /> }
@@ -99,7 +101,6 @@ class TableComponent extends React.Component { addCardFromTemplate={this.addCardFromTemplate} addCardTemplate={this.addCardTemplate} editCardTemplate={this.editCardTemplate} - deleteCardTemplate={this.deleteCardTemplate} /> {/* Main content */} @@ -266,6 +267,9 @@ class TableComponent extends React.Component { } console.log('STILL WORKING') }} + showCard={(cardId) => { + this.setState({shownCardId: cardId}) + }} />) this.cardIdToRowMap.set(card.id, tableRowRef) @@ -303,20 +307,22 @@ class TableComponent extends React.Component { this.addCard(true) } - private addCardFromTemplate = async (cardTemplate?: Card) => { - this.addCard(true, cardTemplate) + private addCardFromTemplate = async (cardTemplateId?: string) => { + this.addCard(true, cardTemplateId) } - private addCard = async (show = false, cardTemplate?: Card) => { + private addCard = async (show = false, cardTemplateId?: string) => { const {boardTree} = this.props let card: MutableCard let blocksToInsert: IBlock[] - if (cardTemplate) { - const templateCardTree = new MutableCardTree(cardTemplate.id) + if (cardTemplateId) { + const templateCardTree = new MutableCardTree(cardTemplateId) await templateCardTree.sync() - const newCardTree = templateCardTree.duplicateFromTemplate() + const newCardTree = templateCardTree.templateCopy() card = newCardTree.card + card.isTemplate = false + card.title = '' blocksToInsert = [newCardTree.card, ...newCardTree.contents] } else { card = new MutableCard() @@ -330,7 +336,7 @@ class TableComponent extends React.Component { 'add card', async () => { if (show) { - this.setState({shownCard: card}) + this.setState({shownCardId: card.id}) } else { // Focus on this card's title inline on next render this.cardIdToFocusOnRender = card.id @@ -350,17 +356,13 @@ class TableComponent extends React.Component { cardTemplate, 'add card', async () => { - this.setState({shownCard: cardTemplate}) + this.setState({shownCardId: cardTemplate.id}) }, ) } - private editCardTemplate = (cardTemplate: Card) => { - this.setState({shownCard: cardTemplate}) - } - - private deleteCardTemplate = (cardTemplate: Card) => { - mutator.deleteBlock(cardTemplate, 'delete card template') + private editCardTemplate = (cardTemplateId: string) => { + this.setState({shownCardId: cardTemplateId}) } private async onDropToColumn(template: IPropertyTemplate) { diff --git a/webapp/src/components/tableRow.tsx b/webapp/src/components/tableRow.tsx index 26a8253f0..41d7c4aeb 100644 --- a/webapp/src/components/tableRow.tsx +++ b/webapp/src/components/tableRow.tsx @@ -12,8 +12,6 @@ import Editable from '../widgets/editable' import Button from '../widgets/buttons/button' import PropertyValueElement from './propertyValueElement' -import {CardDialog} from './cardDialog' -import RootPortal from './rootPortal' import './tableRow.scss' @@ -22,10 +20,10 @@ type Props = { card: Card focusOnMount: boolean onSaveWithEnter: () => void + showCard: (cardId: string) => void } type State = { - showCard: boolean title: string } @@ -34,7 +32,6 @@ class TableRow extends React.Component { constructor(props: Props) { super(props) this.state = { - showCard: false, title: props.card.title, } } @@ -84,21 +81,13 @@ class TableRow extends React.Component {
-
- {this.state.showCard && - - this.setState({showCard: false})} - /> - }
{/* Columns, one per property */} diff --git a/webapp/src/components/viewHeader.tsx b/webapp/src/components/viewHeader.tsx index 6ae598756..35240dc76 100644 --- a/webapp/src/components/viewHeader.tsx +++ b/webapp/src/components/viewHeader.tsx @@ -6,7 +6,7 @@ import {injectIntl, IntlShape, FormattedMessage} from 'react-intl' import {Archiver} from '../archiver' import {ISortOption, MutableBoardView} from '../blocks/boardView' import {BlockIcons} from '../blockIcons' -import {Card, MutableCard} from '../blocks/card' +import {MutableCard} from '../blocks/card' import {IPropertyTemplate} from '../blocks/board' import {BoardTree} from '../viewModel/boardTree' import ViewMenu from '../components/viewMenu' @@ -38,10 +38,9 @@ type Props = { showView: (id: string) => void setSearchText: (text: string) => void addCard: () => void - addCardFromTemplate: (cardTemplate?: Card) => void + addCardFromTemplate: (cardTemplateId?: string) => void addCardTemplate: () => void - editCardTemplate: (cardTemplate: Card) => void - deleteCardTemplate: (cardTemplate: Card) => void + editCardTemplate: (cardTemplateId: string) => void withGroupBy?: boolean intl: IntlShape } @@ -406,7 +405,7 @@ class ViewHeader extends React.Component { id={cardTemplate.id} name={cardTemplate.title || intl.formatMessage({id: 'ViewHeader.untitled', defaultMessage: 'Untitled'})} onClick={() => { - this.props.addCardFromTemplate(cardTemplate) + this.props.addCardFromTemplate(cardTemplate.id) }} rightIcon={ @@ -416,15 +415,15 @@ class ViewHeader extends React.Component { id='edit' name={intl.formatMessage({id: 'ViewHeader.edit-template', defaultMessage: 'Edit'})} onClick={() => { - this.props.editCardTemplate(cardTemplate) + this.props.editCardTemplate(cardTemplate.id) }} /> } id='delete' name={intl.formatMessage({id: 'ViewHeader.delete-template', defaultMessage: 'Delete'})} - onClick={() => { - this.props.deleteCardTemplate(cardTemplate) + onClick={async () => { + await mutator.deleteBlock(cardTemplate, 'delete card template') }} /> diff --git a/webapp/src/viewModel/cardTree.ts b/webapp/src/viewModel/cardTree.ts index 32c77133e..1ca71d959 100644 --- a/webapp/src/viewModel/cardTree.ts +++ b/webapp/src/viewModel/cardTree.ts @@ -13,6 +13,7 @@ interface CardTree { readonly contents: readonly IOrderedBlock[] mutableCopy(): MutableCardTree + templateCopy(): MutableCardTree } class MutableCardTree implements CardTree { @@ -57,8 +58,9 @@ class MutableCardTree implements CardTree { return cardTree } - duplicateFromTemplate(): MutableCardTree { - const card = this.card.newCardFromTemplate() + templateCopy(): MutableCardTree { + const card = this.card.duplicate() + const contents: IOrderedBlock[] = this.contents.map((content) => { const copy = MutableBlock.duplicate(content) copy.parentId = card.id diff --git a/webapp/src/widgets/menu/menu.scss b/webapp/src/widgets/menu/menu.scss index cd6f8a090..24e4aca46 100644 --- a/webapp/src/widgets/menu/menu.scss +++ b/webapp/src/widgets/menu/menu.scss @@ -33,6 +33,7 @@ flex-direction: row; align-items: center; + white-space: nowrap; font-weight: 400; padding: 2px 10px; cursor: pointer; From 95164e0cf00dadfba73a5288e91e1d32b751cdfa Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Wed, 11 Nov 2020 09:33:34 -0800 Subject: [PATCH 28/61] cleanup addCardTemplate --- webapp/src/components/boardComponent.tsx | 27 ++++++++++-------------- webapp/src/components/tableComponent.tsx | 5 +++-- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/webapp/src/components/boardComponent.tsx b/webapp/src/components/boardComponent.tsx index d7b8f7bb6..729f7eaca 100644 --- a/webapp/src/components/boardComponent.tsx +++ b/webapp/src/components/boardComponent.tsx @@ -155,7 +155,7 @@ class BoardComponent extends React.Component { setSearchText={this.props.setSearchText} addCard={() => this.addCard()} addCardFromTemplate={this.addCardFromTemplate} - addCardTemplate={() => this.addCardTemplate()} + addCardTemplate={this.addCardTemplate} editCardTemplate={this.editCardTemplate} withGroupBy={true} /> @@ -526,26 +526,21 @@ class BoardComponent extends React.Component { ) } - private async addCardTemplate(groupByOptionId?: string): Promise { + private addCardTemplate = async () => { const {boardTree} = this.props - const {activeView, board} = boardTree const cardTemplate = new MutableCard() cardTemplate.isTemplate = true cardTemplate.parentId = boardTree.board.id - cardTemplate.properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties) - if (boardTree.groupByProperty) { - if (groupByOptionId) { - cardTemplate.properties[boardTree.groupByProperty.id] = groupByOptionId - } else { - delete cardTemplate.properties[boardTree.groupByProperty.id] - } - } - await mutator.insertBlock(cardTemplate, 'add card template', async () => { - this.setState({shownCardId: cardTemplate.id}) - }, async () => { - this.setState({shownCardId: undefined}) - }) + await mutator.insertBlock( + cardTemplate, + 'add card template', + async () => { + this.setState({shownCardId: cardTemplate.id}) + }, async () => { + this.setState({shownCardId: undefined}) + }, + ) } private editCardTemplate = (cardTemplateId: string) => { diff --git a/webapp/src/components/tableComponent.tsx b/webapp/src/components/tableComponent.tsx index e0eb5cdd7..08f31de88 100644 --- a/webapp/src/components/tableComponent.tsx +++ b/webapp/src/components/tableComponent.tsx @@ -351,12 +351,13 @@ class TableComponent extends React.Component { const cardTemplate = new MutableCard() cardTemplate.isTemplate = true cardTemplate.parentId = boardTree.board.id - cardTemplate.icon = BlockIcons.shared.randomIcon() await mutator.insertBlock( cardTemplate, - 'add card', + 'add card template', async () => { this.setState({shownCardId: cardTemplate.id}) + }, async () => { + this.setState({shownCardId: undefined}) }, ) } From 68c4c07af92a546e5786482b650b6cf723b09de9 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Wed, 11 Nov 2020 09:51:08 -0800 Subject: [PATCH 29/61] Remove highlight on drop to hidden column --- webapp/src/components/boardComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/components/boardComponent.tsx b/webapp/src/components/boardComponent.tsx index 729f7eaca..0a71a80df 100644 --- a/webapp/src/components/boardComponent.tsx +++ b/webapp/src/components/boardComponent.tsx @@ -444,7 +444,7 @@ class BoardComponent extends React.Component { e.preventDefault() }} onDrop={(e) => { - (e.target as HTMLElement).classList.remove('dragover') + ref.current.classList.remove('dragover') e.preventDefault() if (this.draggedCards?.length < 1) { return From 937af1e34941c67669ec54a7dfbdad49a85aefa1 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Wed, 11 Nov 2020 13:49:25 -0800 Subject: [PATCH 30/61] Fix incremental delete --- webapp/src/viewModel/boardTree.ts | 2 +- webapp/src/viewModel/cardTree.ts | 2 +- webapp/src/viewModel/workspaceTree.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index 274016c17..4e05a4a43 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -61,7 +61,7 @@ class MutableBoardTree implements BoardTree { } incrementalUpdate(updatedBlocks: IBlock[]): boolean { - const relevantBlocks = updatedBlocks.filter((block) => block.id === this.boardId || block.parentId === this.boardId) + const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.id === this.boardId || block.parentId === this.boardId) if (relevantBlocks.length < 1) { return false } diff --git a/webapp/src/viewModel/cardTree.ts b/webapp/src/viewModel/cardTree.ts index 1ca71d959..5ddafd20c 100644 --- a/webapp/src/viewModel/cardTree.ts +++ b/webapp/src/viewModel/cardTree.ts @@ -32,7 +32,7 @@ class MutableCardTree implements CardTree { } incrementalUpdate(updatedBlocks: IBlock[]): boolean { - const relevantBlocks = updatedBlocks.filter((block) => block.id === this.cardId || block.parentId === this.cardId) + const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.id === this.cardId || block.parentId === this.cardId) if (relevantBlocks.length < 1) { return false } diff --git a/webapp/src/viewModel/workspaceTree.ts b/webapp/src/viewModel/workspaceTree.ts index 2b7a0a617..dd590e277 100644 --- a/webapp/src/viewModel/workspaceTree.ts +++ b/webapp/src/viewModel/workspaceTree.ts @@ -27,7 +27,7 @@ class MutableWorkspaceTree { } incrementalUpdate(updatedBlocks: IBlock[]): boolean { - const relevantBlocks = updatedBlocks.filter((block) => block.type === 'board' || block.type === 'view') + const relevantBlocks = updatedBlocks.filter((block) => block.deleteAt !== 0 || block.type === 'board' || block.type === 'view') if (relevantBlocks.length < 1) { return false } From 37fd30413bfae96fb4a3d6f04ac112c3fb394d6c Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 10:16:59 -0800 Subject: [PATCH 31/61] Duplicate board --- server/api/api.go | 21 +++++++++-- server/app/blocks.go | 8 +++- server/services/store/sqlstore/blocks.go | 48 +++++++++++++++++++----- server/services/store/store.go | 3 +- webapp/src/components/boardCard.tsx | 4 +- webapp/src/components/sidebar.tsx | 19 +++++++++- webapp/src/components/viewTitle.scss | 1 + webapp/src/mutator.ts | 41 ++++++++++++++++++++ webapp/src/octoClient.ts | 6 +-- webapp/src/octoUtils.tsx | 34 +++++++++++++++-- 10 files changed, 159 insertions(+), 26 deletions(-) diff --git a/server/api/api.go b/server/api/api.go index a33e61837..b44916add 100644 --- a/server/api/api.go +++ b/server/api/api.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "path/filepath" + "strconv" "strings" "github.com/gorilla/mux" @@ -152,7 +153,21 @@ func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) blockID := vars["blockID"] - blocks, err := a.app().GetSubTree(blockID) + query := r.URL.Query() + levels, err := strconv.ParseInt(query.Get("l"), 10, 32) + if err != nil { + levels = 2 + } + + if levels != 2 && levels != 3 { + log.Printf(`ERROR Invalid levels: %d`, levels) + errorData := map[string]string{"description": "invalid levels"} + errorResponse(w, http.StatusInternalServerError, errorData) + + return + } + + blocks, err := a.app().GetSubTree(blockID, int(levels)) if err != nil { log.Printf(`ERROR: %v`, r) errorResponse(w, http.StatusInternalServerError, nil) @@ -160,7 +175,7 @@ func (a *API) handleGetSubTree(w http.ResponseWriter, r *http.Request) { return } - log.Printf("GetSubTree blockID: %s, %d result(s)", blockID, len(blocks)) + log.Printf("GetSubTree (%v) blockID: %s, %d result(s)", levels, blockID, len(blocks)) json, err := json.Marshal(blocks) if err != nil { log.Printf(`ERROR json.Marshal: %v`, r) @@ -298,7 +313,7 @@ func errorResponse(w http.ResponseWriter, code int, message map[string]string) { data = []byte("{}") } w.WriteHeader(code) - fmt.Fprint(w, data) + w.Write(data) } func addUserID(rw http.ResponseWriter, req *http.Request, next http.Handler) { diff --git a/server/app/blocks.go b/server/app/blocks.go index 757057d01..3e60c8a58 100644 --- a/server/app/blocks.go +++ b/server/app/blocks.go @@ -51,8 +51,12 @@ func (a *App) InsertBlocks(blocks []model.Block) error { return nil } -func (a *App) GetSubTree(blockID string) ([]model.Block, error) { - return a.store.GetSubTree(blockID) +func (a *App) GetSubTree(blockID string, levels int) ([]model.Block, error) { + // Only 2 or 3 levels are supported for now + if levels >= 3 { + return a.store.GetSubTree3(blockID) + } + return a.store.GetSubTree2(blockID) } func (a *App) GetAllBlocks() ([]model.Block, error) { diff --git a/server/services/store/sqlstore/blocks.go b/server/services/store/sqlstore/blocks.go index b053edebf..8830eedc7 100644 --- a/server/services/store/sqlstore/blocks.go +++ b/server/services/store/sqlstore/blocks.go @@ -15,7 +15,10 @@ import ( func (s *SQLStore) latestsBlocksSubquery() sq.SelectBuilder { internalQuery := sq.Select("*", "ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn").From("blocks") - return sq.Select("*").FromSelect(internalQuery, "a").Where(sq.Eq{"rn": 1}) + return sq.Select("*"). + FromSelect(internalQuery, "a"). + Where(sq.Eq{"rn": 1}). + Where(sq.Eq{"delete_at": 0}) } func (s *SQLStore) GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error) { @@ -24,7 +27,6 @@ func (s *SQLStore) GetBlocksWithParentAndType(parentID string, blockType string) "COALESCE(\"fields\", '{}')", "create_at", "update_at", "delete_at"). FromSelect(s.latestsBlocksSubquery(), "latest"). - Where(sq.Eq{"delete_at": 0}). Where(sq.Eq{"parent_id": parentID}). Where(sq.Eq{"type": blockType}) @@ -44,7 +46,6 @@ func (s *SQLStore) GetBlocksWithParent(parentID string) ([]model.Block, error) { "COALESCE(\"fields\", '{}')", "create_at", "update_at", "delete_at"). FromSelect(s.latestsBlocksSubquery(), "latest"). - Where(sq.Eq{"delete_at": 0}). Where(sq.Eq{"parent_id": parentID}) rows, err := query.Query() @@ -63,7 +64,6 @@ func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) { "COALESCE(\"fields\", '{}')", "create_at", "update_at", "delete_at"). FromSelect(s.latestsBlocksSubquery(), "latest"). - Where(sq.Eq{"delete_at": 0}). Where(sq.Eq{"type": blockType}) rows, err := query.Query() @@ -76,13 +76,13 @@ func (s *SQLStore) GetBlocksWithType(blockType string) ([]model.Block, error) { return blocksFromRows(rows) } -func (s *SQLStore) GetSubTree(blockID string) ([]model.Block, error) { +// GetSubTree2 returns blocks within 2 levels of the given blockID +func (s *SQLStore) GetSubTree2(blockID string) ([]model.Block, error) { query := s.getQueryBuilder(). Select("id", "parent_id", "schema", "type", "title", "COALESCE(\"fields\", '{}')", "create_at", "update_at", "delete_at"). FromSelect(s.latestsBlocksSubquery(), "latest"). - Where(sq.Eq{"delete_at": 0}). Where(sq.Or{sq.Eq{"id": blockID}, sq.Eq{"parent_id": blockID}}) rows, err := query.Query() @@ -95,13 +95,44 @@ func (s *SQLStore) GetSubTree(blockID string) ([]model.Block, error) { return blocksFromRows(rows) } +// GetSubTree3 returns blocks within 3 levels of the given blockID +func (s *SQLStore) GetSubTree3(blockID string) ([]model.Block, error) { + // This first subquery returns repeated blocks + subquery1 := sq.Select("l3.id", "l3.parent_id", "l3.schema", "l3.type", "l3.title", + "l3.fields", "l3.create_at", "l3.update_at", + "l3.delete_at"). + FromSelect(s.latestsBlocksSubquery(), "l1"). + JoinClause(s.latestsBlocksSubquery().Prefix("JOIN (").Suffix(") l2 on l2.parent_id = l1.id or l2.id = l1.id")). + JoinClause(s.latestsBlocksSubquery().Prefix("JOIN (").Suffix(") l3 on l3.parent_id = l2.id or l3.id = l2.id")). + Where(sq.Eq{"l1.id": blockID}) + + // This second subquery is used to return distinct blocks + // We can't use DISTINCT because JSON columns in Postgres don't support it, and SQLite doesn't support DISTINCT ON + subquery2 := sq.Select("*", "ROW_NUMBER() OVER (PARTITION BY id) AS rn"). + FromSelect(subquery1, "sub1") + + query := s.getQueryBuilder().Select("id", "parent_id", "schema", "type", "title", + "COALESCE(\"fields\", '{}')", "create_at", "update_at", + "delete_at"). + FromSelect(subquery2, "sub2"). + Where(sq.Eq{"rn": 1}) + + rows, err := query.Query() + if err != nil { + log.Printf(`getSubTree3 ERROR: %v`, err) + + return nil, err + } + + return blocksFromRows(rows) +} + func (s *SQLStore) GetAllBlocks() ([]model.Block, error) { query := s.getQueryBuilder(). Select("id", "parent_id", "schema", "type", "title", "COALESCE(\"fields\", '{}')", "create_at", "update_at", "delete_at"). - FromSelect(s.latestsBlocksSubquery(), "latest"). - Where(sq.Eq{"delete_at": 0}) + FromSelect(s.latestsBlocksSubquery(), "latest") rows, err := query.Query() if err != nil { @@ -156,7 +187,6 @@ func blocksFromRows(rows *sql.Rows) ([]model.Block, error) { func (s *SQLStore) GetParentID(blockID string) (string, error) { query := s.getQueryBuilder().Select("parent_id"). FromSelect(s.latestsBlocksSubquery(), "latest"). - Where(sq.Eq{"delete_at": 0}). Where(sq.Eq{"id": blockID}) row := query.QueryRow() diff --git a/server/services/store/store.go b/server/services/store/store.go index 6d16a5552..6c693ae5d 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -8,7 +8,8 @@ type Store interface { GetBlocksWithParentAndType(parentID string, blockType string) ([]model.Block, error) GetBlocksWithParent(parentID string) ([]model.Block, error) GetBlocksWithType(blockType string) ([]model.Block, error) - GetSubTree(blockID string) ([]model.Block, error) + GetSubTree2(blockID string) ([]model.Block, error) + GetSubTree3(blockID string) ([]model.Block, error) GetAllBlocks() ([]model.Block, error) GetParentID(blockID string) (string, error) InsertBlock(block model.Block) error diff --git a/webapp/src/components/boardCard.tsx b/webapp/src/components/boardCard.tsx index cf550f3d9..f4cc3cb3d 100644 --- a/webapp/src/components/boardCard.tsx +++ b/webapp/src/components/boardCard.tsx @@ -106,9 +106,7 @@ class BoardCard extends React.Component { id='duplicate' name={intl.formatMessage({id: 'BoardCard.duplicate', defaultMessage: 'Duplicate'})} onClick={() => { - const newCard = MutableBlock.duplicate(card) - newCard.title = `Copy of ${card.title}` - mutator.insertBlock(newCard, 'duplicate card') + mutator.duplicateCard(card.id) }} /> diff --git a/webapp/src/components/sidebar.tsx b/webapp/src/components/sidebar.tsx index fa19c5a48..b96046a0f 100644 --- a/webapp/src/components/sidebar.tsx +++ b/webapp/src/components/sidebar.tsx @@ -120,7 +120,7 @@ class Sidebar extends React.Component { }/> } onClick={async () => { @@ -137,6 +137,23 @@ class Sidebar extends React.Component { ) }} /> + + { + await mutator.duplicateBoard( + board.id, + 'duplicate board', + async (newBoardId) => { + newBoardId && this.props.showBoard(newBoardId) + }, + async () => { + this.props.showBoard(board.id) + }, + ) + }} + />
diff --git a/webapp/src/components/viewTitle.scss b/webapp/src/components/viewTitle.scss index 552638a1a..5f05d42b0 100644 --- a/webapp/src/components/viewTitle.scss +++ b/webapp/src/components/viewTitle.scss @@ -26,5 +26,6 @@ .Editable { margin-bottom: 0px; + flex-grow: 1; } } diff --git a/webapp/src/mutator.ts b/webapp/src/mutator.ts index db5c35840..fa0992516 100644 --- a/webapp/src/mutator.ts +++ b/webapp/src/mutator.ts @@ -11,6 +11,7 @@ import {FilterGroup} from './filterGroup' import octoClient from './octoClient' import undoManager from './undomanager' import {Utils} from './utils' +import {OctoUtils} from './octoUtils' // // The Mutator is used to make all changes to server state @@ -460,6 +461,46 @@ class Mutator { await this.updateBlock(newView, view, description) } + // Duplicate + + async duplicateCard(cardId: string, description = 'duplicate card', afterRedo?: (newBoardId: string) => Promise, beforeUndo?: () => Promise): Promise<[IBlock[], string]> { + const blocks = await octoClient.getSubtree(cardId, 2) + let [newBlocks, idMap] = OctoUtils.duplicateBlockTree(blocks, cardId) + newBlocks = newBlocks.filter(o => o.type !== 'comment') + Utils.log(`duplicateCard: duplicating ${newBlocks.length} blocks`) + const newCardId = idMap[cardId] + const newCard = newBlocks.find((o) => o.id === newCardId) + newCard.title = `Copy of ${newCard.title || ''}` + await this.insertBlocks( + newBlocks, + description, + async () => { + await afterRedo?.(newCardId) + }, + beforeUndo, + ) + return [newBlocks, newCardId] + } + + async duplicateBoard(boardId: string, description = 'duplicate board', afterRedo?: (newBoardId: string) => Promise, beforeUndo?: () => Promise): Promise<[IBlock[], string]> { + const blocks = await octoClient.getSubtree(boardId, 3) + let [newBlocks, idMap] = OctoUtils.duplicateBlockTree(blocks, boardId) + newBlocks = newBlocks.filter(o => o.type !== 'comment') + Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`) + const newBoardId = idMap[boardId] + const newBoard = newBlocks.find((o) => o.id === newBoardId) + newBoard.title = `Copy of ${newBoard.title || ''}` + await this.insertBlocks( + newBlocks, + description, + async () => { + await afterRedo?.(newBoardId) + }, + beforeUndo, + ) + return [newBlocks, newBoardId] + } + // Other methods // Not a mutator, but convenient to put here since Mutator wraps OctoClient diff --git a/webapp/src/octoClient.ts b/webapp/src/octoClient.ts index 071967529..d4f6cde3c 100644 --- a/webapp/src/octoClient.ts +++ b/webapp/src/octoClient.ts @@ -14,8 +14,8 @@ class OctoClient { Utils.log(`OctoClient serverUrl: ${this.serverUrl}`) } - async getSubtree(rootId?: string): Promise { - const path = `/api/v1/blocks/${rootId}/subtree` + async getSubtree(rootId?: string, levels = 2): Promise { + const path = `/api/v1/blocks/${rootId}/subtree?l=${levels}` const response = await fetch(this.serverUrl + path) const blocks = (await response.json() || []) as IMutableBlock[] this.fixBlocks(blocks) @@ -125,7 +125,7 @@ class OctoClient { async insertBlocks(blocks: IBlock[]): Promise { Utils.log(`insertBlocks: ${blocks.length} blocks(s)`) blocks.forEach((block) => { - Utils.log(`\t ${block.type}, ${block.id}`) + Utils.log(`\t ${block.type}, ${block.id}, ${block.title?.substr(0, 50) || ''}`) }) const body = JSON.stringify(blocks) return await fetch(this.serverUrl + '/api/v1/blocks', { diff --git a/webapp/src/octoUtils.tsx b/webapp/src/octoUtils.tsx index 14c10e596..b57528544 100644 --- a/webapp/src/octoUtils.tsx +++ b/webapp/src/octoUtils.tsx @@ -1,10 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react' - -import {IBlock, MutableBlock} from './blocks/block' +import {IBlock, IMutableBlock, MutableBlock} from './blocks/block' import {IPropertyTemplate, MutableBoard} from './blocks/board' -import {MutableBoardView} from './blocks/boardView' +import {BoardView, MutableBoardView} from './blocks/boardView' import {MutableCard} from './blocks/card' import {MutableCommentBlock} from './blocks/commentBlock' import {MutableImageBlock} from './blocks/imageBlock' @@ -88,6 +86,34 @@ class OctoUtils { newBlocks.push(...updatedAndNotDeletedBlocks) return newBlocks } + + // Creates a copy of the blocks with new ids and parentIDs + static duplicateBlockTree(blocks: IBlock[], rootBlockId?: string): [MutableBlock[], Readonly>] { + const idMap: Record = {} + const newBlocks = blocks.map((block) => { + const newBlock = this.hydrateBlock(block) + newBlock.id = Utils.createGuid() + idMap[block.id] = newBlock.id + return newBlock + }) + + const newRootBlockId = rootBlockId ? idMap[rootBlockId] : undefined + newBlocks.forEach((newBlock) => { + // Note: Don't remap the parent of the new root block + if (newBlock.id !== newRootBlockId && newBlock.parentId) { + newBlock.parentId = idMap[newBlock.parentId] || newBlock.parentId + Utils.assert(newBlock.parentId, `Block ${newBlock.id} (${newBlock.type} ${newBlock.title}) has no parent`) + } + + // Remap manual card order + if (newBlock.type === 'view') { + const view = newBlock as MutableBoardView + view.cardOrder = view.cardOrder.map(o => idMap[o]) + } + }) + + return [newBlocks, idMap] + } } export {OctoUtils} From 257a74954db36ba2725f5c0cab6233b2a3997531 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 10:48:08 -0800 Subject: [PATCH 32/61] Unit tests for store.getSubTree --- server/services/store/mockstore/mockstore.go | 30 +++-- server/services/store/sqlstore/blocks_test.go | 106 ++++++++++++++++++ .../services/store/sqlstore/sqlstore_test.go | 25 +++++ 3 files changed, 153 insertions(+), 8 deletions(-) diff --git a/server/services/store/mockstore/mockstore.go b/server/services/store/mockstore/mockstore.go index fc0d53e46..8fe7b3235 100644 --- a/server/services/store/mockstore/mockstore.go +++ b/server/services/store/mockstore/mockstore.go @@ -5,10 +5,9 @@ package mockstore import ( - reflect "reflect" - gomock "github.com/golang/mock/gomock" model "github.com/mattermost/mattermost-octo-tasks/server/model" + reflect "reflect" ) // MockStore is a mock of Store interface @@ -123,19 +122,34 @@ func (mr *MockStoreMockRecorder) GetParentID(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetParentID", reflect.TypeOf((*MockStore)(nil).GetParentID), arg0) } -// GetSubTree mocks base method -func (m *MockStore) GetSubTree(arg0 string) ([]model.Block, error) { +// GetSubTree2 mocks base method +func (m *MockStore) GetSubTree2(arg0 string) ([]model.Block, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSubTree", arg0) + ret := m.ctrl.Call(m, "GetSubTree2", arg0) ret0, _ := ret[0].([]model.Block) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetSubTree indicates an expected call of GetSubTree -func (mr *MockStoreMockRecorder) GetSubTree(arg0 interface{}) *gomock.Call { +// GetSubTree2 indicates an expected call of GetSubTree2 +func (mr *MockStoreMockRecorder) GetSubTree2(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree", reflect.TypeOf((*MockStore)(nil).GetSubTree), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree2", reflect.TypeOf((*MockStore)(nil).GetSubTree2), arg0) +} + +// GetSubTree3 mocks base method +func (m *MockStore) GetSubTree3(arg0 string) ([]model.Block, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSubTree3", arg0) + ret0, _ := ret[0].([]model.Block) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSubTree3 indicates an expected call of GetSubTree3 +func (mr *MockStoreMockRecorder) GetSubTree3(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubTree3", reflect.TypeOf((*MockStore)(nil).GetSubTree3), arg0) } // GetSystemSettings mocks base method diff --git a/server/services/store/sqlstore/blocks_test.go b/server/services/store/sqlstore/blocks_test.go index 882d2c567..bb7292e08 100644 --- a/server/services/store/sqlstore/blocks_test.go +++ b/server/services/store/sqlstore/blocks_test.go @@ -36,3 +36,109 @@ func TestInsertBlock(t *testing.T) { require.NoError(t, err) require.Empty(t, blocks) } + +func TestGetSubTree2(t *testing.T) { + store, tearDown := SetupTests(t) + defer tearDown() + + blocks, err := store.GetAllBlocks() + require.NoError(t, err) + require.Empty(t, blocks) + + blocksToInsert := []model.Block{ + model.Block{ + ID: "parent", + }, + model.Block{ + ID: "child1", + ParentID: "parent", + }, + model.Block{ + ID: "child2", + ParentID: "parent", + }, + model.Block{ + ID: "grandchild1", + ParentID: "child1", + }, + model.Block{ + ID: "grandchild2", + ParentID: "child2", + }, + model.Block{ + ID: "greatgrandchild1", + ParentID: "grandchild1", + }, + } + + InsertBlocks(t, store, blocksToInsert) + + blocks, err = store.GetSubTree2("parent") + require.NoError(t, err) + require.Len(t, blocks, 3) + require.True(t, ContainsBlockWithID(blocks, "parent")) + require.True(t, ContainsBlockWithID(blocks, "child1")) + require.True(t, ContainsBlockWithID(blocks, "child2")) + + // Wait for not colliding the ID+insert_at key + time.Sleep(1 * time.Millisecond) + DeleteBlocks(t, store, blocksToInsert) + + blocks, err = store.GetAllBlocks() + require.NoError(t, err) + require.Empty(t, blocks) +} + +func TestGetSubTree3(t *testing.T) { + store, tearDown := SetupTests(t) + defer tearDown() + + blocks, err := store.GetAllBlocks() + require.NoError(t, err) + require.Empty(t, blocks) + + blocksToInsert := []model.Block{ + model.Block{ + ID: "parent", + }, + model.Block{ + ID: "child1", + ParentID: "parent", + }, + model.Block{ + ID: "child2", + ParentID: "parent", + }, + model.Block{ + ID: "grandchild1", + ParentID: "child1", + }, + model.Block{ + ID: "grandchild2", + ParentID: "child2", + }, + model.Block{ + ID: "greatgrandchild1", + ParentID: "grandchild1", + }, + } + + InsertBlocks(t, store, blocksToInsert) + + blocks, err = store.GetSubTree3("parent") + require.NoError(t, err) + require.Len(t, blocks, 5) + require.True(t, ContainsBlockWithID(blocks, "parent")) + require.True(t, ContainsBlockWithID(blocks, "child1")) + require.True(t, ContainsBlockWithID(blocks, "child2")) + require.True(t, ContainsBlockWithID(blocks, "grandchild1")) + require.True(t, ContainsBlockWithID(blocks, "grandchild2")) + + // Wait for not colliding the ID+insert_at key + time.Sleep(1 * time.Millisecond) + DeleteBlocks(t, store, blocksToInsert) + + blocks, err = store.GetAllBlocks() + require.NoError(t, err) + require.Empty(t, blocks) +} diff --git a/server/services/store/sqlstore/sqlstore_test.go b/server/services/store/sqlstore/sqlstore_test.go index 5ed292531..2e092639e 100644 --- a/server/services/store/sqlstore/sqlstore_test.go +++ b/server/services/store/sqlstore/sqlstore_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + "github.com/mattermost/mattermost-octo-tasks/server/model" "github.com/stretchr/testify/require" ) @@ -28,3 +29,27 @@ func SetupTests(t *testing.T) (*SQLStore, func()) { return store, tearDown } + +func InsertBlocks(t *testing.T, s *SQLStore, blocks []model.Block) { + for _, block := range blocks { + err := s.InsertBlock(block) + require.NoError(t, err) + } +} + +func DeleteBlocks(t *testing.T, s *SQLStore, blocks []model.Block) { + for _, block := range blocks { + err := s.DeleteBlock(block.ID) + require.NoError(t, err) + } +} + +func ContainsBlockWithID(blocks []model.Block, blockID string) bool { + for _, block := range blocks { + if block.ID == blockID { + return true + } + } + + return false +} From 44f0a2e65bb57031d82141a813353280d61fc780 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 11:18:19 -0800 Subject: [PATCH 33/61] Fix some strictNullChecks --- webapp/src/csvExporter.ts | 15 ++++++---- webapp/src/mutator.ts | 60 +++++++++++++++++++++++++++----------- webapp/src/octoListener.ts | 12 ++++---- webapp/src/octoUtils.tsx | 4 +-- 4 files changed, 60 insertions(+), 31 deletions(-) diff --git a/webapp/src/csvExporter.ts b/webapp/src/csvExporter.ts index 7c9894b94..9cf8402d6 100644 --- a/webapp/src/csvExporter.ts +++ b/webapp/src/csvExporter.ts @@ -10,7 +10,11 @@ class CsvExporter { const {activeView} = boardTree const viewToExport = view ?? activeView - const rows = CsvExporter.generateTableArray(boardTree, view) + if (!viewToExport) { + return + } + + const rows = CsvExporter.generateTableArray(boardTree, viewToExport) let csvContent = 'data:text/csv;charset=utf-8,' @@ -19,7 +23,7 @@ class CsvExporter { csvContent += encodedRow + '\r\n' }) - const filename = `${Utils.sanitizeFilename(viewToExport.title)}.csv` + const filename = `${Utils.sanitizeFilename(viewToExport.title || 'Untitled')}.csv` const encodedUri = encodeURI(csvContent) const link = document.createElement('a') link.style.display = 'none' @@ -32,9 +36,8 @@ class CsvExporter { // TODO: Remove or reuse link } - private static generateTableArray(boardTree: BoardTree, view?: BoardView): string[][] { - const {board, cards, activeView} = boardTree - const viewToExport = view ?? activeView + private static generateTableArray(boardTree: BoardTree, viewToExport: BoardView): string[][] { + const {board, cards} = boardTree const rows: string[][] = [] const visibleProperties = board.cardProperties.filter((template) => viewToExport.visiblePropertyIds.includes(template.id)) @@ -54,7 +57,7 @@ class CsvExporter { const propertyValue = card.properties[template.id] const displayValue = OctoUtils.propertyDisplayValue(card, propertyValue, template) || '' if (template.type === 'number') { - const numericValue = propertyValue ? Number(propertyValue).toString() : undefined + const numericValue = propertyValue ? Number(propertyValue).toString() : '' row.push(numericValue) } else { // Export as string diff --git a/webapp/src/mutator.ts b/webapp/src/mutator.ts index fa0992516..14566a98c 100644 --- a/webapp/src/mutator.ts +++ b/webapp/src/mutator.ts @@ -20,7 +20,7 @@ import {OctoUtils} from './octoUtils' class Mutator { private undoGroupId?: string - private beginUndoGroup(): string { + private beginUndoGroup(): string | undefined { if (this.undoGroupId) { Utils.assertFailure('UndoManager does not support nested groups') return @@ -44,7 +44,9 @@ class Mutator { } catch (err) { Utils.assertFailure(`ERROR: ${err?.toString?.()}`) } - this.endUndoGroup(groupId) + if (groupId) { + this.endUndoGroup(groupId) + } } async updateBlock(newBlock: IBlock, oldBlock: IBlock, description: string): Promise { @@ -145,6 +147,10 @@ class Mutator { newBlock = board break } + default: { + Utils.assertFailure(`changeIcon: Invalid block type: ${block.type}`) + return + } } await this.updateBlock(newBlock, block, description) @@ -160,6 +166,10 @@ class Mutator { async insertPropertyTemplate(boardTree: BoardTree, index = -1, template?: IPropertyTemplate) { const {board, activeView} = boardTree + if (!activeView) { + Utils.assertFailure('insertPropertyTemplate: no activeView') + return + } if (index < 0) { index = board.cardProperties.length @@ -197,6 +207,10 @@ class Mutator { async duplicatePropertyTemplate(boardTree: BoardTree, propertyId: string) { const {board, activeView} = boardTree + if (!activeView) { + Utils.assertFailure('duplicatePropertyTemplate: no activeView') + return + } const oldBlocks: IBlock[] = [board] @@ -297,7 +311,7 @@ class Mutator { Utils.assert(board.cardProperties.includes(template)) const newBoard = new MutableBoard(board) - const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id) + const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)! newTemplate.options.push(option) await this.updateBlock(newBoard, board, description) @@ -307,7 +321,7 @@ class Mutator { const {board} = boardTree const newBoard = new MutableBoard(board) - const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id) + const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)! newTemplate.options = newTemplate.options.filter((o) => o.id !== option.id) await this.updateBlock(newBoard, board, 'delete option') @@ -318,7 +332,7 @@ class Mutator { Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`) const newBoard = new MutableBoard(board) - const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id) + const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)! newTemplate.options.splice(destIndex, 0, newTemplate.options.splice(srcIndex, 1)[0]) await this.updateBlock(newBoard, board, 'reorder options') @@ -330,8 +344,8 @@ class Mutator { const oldBlocks: IBlock[] = [board] const newBoard = new MutableBoard(board) - const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id) - const newOption = newTemplate.options.find((o) => o.id === option.id) + const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)! + const newOption = newTemplate.options.find((o) => o.id === option.id)! newOption.value = value const changedBlocks: IBlock[] = [newBoard] @@ -342,15 +356,19 @@ class Mutator { async changePropertyOptionColor(board: Board, template: IPropertyTemplate, option: IPropertyOption, color: string) { const newBoard = new MutableBoard(board) - const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id) - const newOption = newTemplate.options.find((o) => o.id === option.id) + const newTemplate = newBoard.cardProperties.find((o) => o.id === template.id)! + const newOption = newTemplate.options.find((o) => o.id === option.id)! newOption.color = color await this.updateBlock(newBoard, board, 'change option color') } async changePropertyValue(card: Card, propertyId: string, value?: string, description = 'change property') { const newCard = new MutableCard(card) - newCard.properties[propertyId] = value + if (value) { + newCard.properties[propertyId] = value + } else { + delete newCard.properties[propertyId] + } await this.updateBlock(newCard, card, description) } @@ -358,7 +376,7 @@ class Mutator { const {board} = boardTree const newBoard = new MutableBoard(board) - const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id) + const newTemplate = newBoard.cardProperties.find((o) => o.id === propertyTemplate.id)! newTemplate.type = type const oldBlocks: IBlock[] = [board] @@ -370,7 +388,11 @@ class Mutator { if (oldValue) { const newValue = propertyTemplate.options.find((o) => o.id === oldValue)?.value const newCard = new MutableCard(card) - newCard.properties[propertyTemplate.id] = newValue + if (newValue) { + newCard.properties[propertyTemplate.id] = newValue + } else { + delete newCard.properties[propertyTemplate.id] + } newBlocks.push(newCard) oldBlocks.push(card) } @@ -382,7 +404,11 @@ class Mutator { if (oldValue) { const newValue = propertyTemplate.options.find((o) => o.value === oldValue)?.id const newCard = new MutableCard(card) - newCard.properties[propertyTemplate.id] = newValue + if (newValue) { + newCard.properties[propertyTemplate.id] = newValue + } else { + delete newCard.properties[propertyTemplate.id] + } newBlocks.push(newCard) oldBlocks.push(card) } @@ -466,10 +492,10 @@ class Mutator { async duplicateCard(cardId: string, description = 'duplicate card', afterRedo?: (newBoardId: string) => Promise, beforeUndo?: () => Promise): Promise<[IBlock[], string]> { const blocks = await octoClient.getSubtree(cardId, 2) let [newBlocks, idMap] = OctoUtils.duplicateBlockTree(blocks, cardId) - newBlocks = newBlocks.filter(o => o.type !== 'comment') + newBlocks = newBlocks.filter((o) => o.type !== 'comment') Utils.log(`duplicateCard: duplicating ${newBlocks.length} blocks`) const newCardId = idMap[cardId] - const newCard = newBlocks.find((o) => o.id === newCardId) + const newCard = newBlocks.find((o) => o.id === newCardId)! newCard.title = `Copy of ${newCard.title || ''}` await this.insertBlocks( newBlocks, @@ -485,10 +511,10 @@ class Mutator { async duplicateBoard(boardId: string, description = 'duplicate board', afterRedo?: (newBoardId: string) => Promise, beforeUndo?: () => Promise): Promise<[IBlock[], string]> { const blocks = await octoClient.getSubtree(boardId, 3) let [newBlocks, idMap] = OctoUtils.duplicateBlockTree(blocks, boardId) - newBlocks = newBlocks.filter(o => o.type !== 'comment') + newBlocks = newBlocks.filter((o) => o.type !== 'comment') Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`) const newBoardId = idMap[boardId] - const newBoard = newBlocks.find((o) => o.id === newBoardId) + const newBoard = newBlocks.find((o) => o.id === newBoardId)! newBoard.title = `Copy of ${newBoard.title || ''}` await this.insertBlocks( newBlocks, diff --git a/webapp/src/octoListener.ts b/webapp/src/octoListener.ts index da05ad394..c19d4d172 100644 --- a/webapp/src/octoListener.ts +++ b/webapp/src/octoListener.ts @@ -23,7 +23,7 @@ type OnChangeHandler = (blocks: IBlock[]) => void // class OctoListener { get isOpen(): boolean { - return this.ws !== undefined + return Boolean(this.ws) } readonly serverUrl: string @@ -31,9 +31,9 @@ class OctoListener { private blockIds: string[] = [] private isInitialized = false - private onChange: OnChangeHandler + private onChange?: OnChangeHandler private updatedBlocks: IBlock[] = [] - private updateTimeout: NodeJS.Timeout + private updateTimeout?: NodeJS.Timeout notificationDelay = 100 reopenDelay = 3000 @@ -124,7 +124,7 @@ class OctoListener { } addBlocks(blockIds: string[]): void { - if (!this.isOpen) { + if (!this.ws) { Utils.assertFailure('OctoListener.addBlocks: ws is not open') return } @@ -139,7 +139,7 @@ class OctoListener { } removeBlocks(blockIds: string[]): void { - if (!this.isOpen) { + if (!this.ws) { Utils.assertFailure('OctoListener.removeBlocks: ws is not open') return } @@ -176,7 +176,7 @@ class OctoListener { } private flushUpdateNotifications() { - this.onChange(this.updatedBlocks) + this.onChange?.(this.updatedBlocks) this.updatedBlocks = [] } } diff --git a/webapp/src/octoUtils.tsx b/webapp/src/octoUtils.tsx index b57528544..cf02ff9fd 100644 --- a/webapp/src/octoUtils.tsx +++ b/webapp/src/octoUtils.tsx @@ -13,7 +13,7 @@ import {Utils} from './utils' class OctoUtils { static propertyDisplayValue(block: IBlock, propertyValue: string | undefined, propertyTemplate: IPropertyTemplate): string | undefined { - let displayValue: string + let displayValue: string | undefined switch (propertyTemplate.type) { case 'select': { // The property value is the id of the template @@ -108,7 +108,7 @@ class OctoUtils { // Remap manual card order if (newBlock.type === 'view') { const view = newBlock as MutableBoardView - view.cardOrder = view.cardOrder.map(o => idMap[o]) + view.cardOrder = view.cardOrder.map((o) => idMap[o]) } }) From 3504dff72ecde6c923273449099b1e4f86d5eb70 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 11:29:48 -0800 Subject: [PATCH 34/61] Fix some strictNullChecks --- webapp/src/blocks/boardView.ts | 4 ++-- webapp/src/pages/boardPage.tsx | 19 ++++++++++--------- webapp/src/viewModel/boardTree.ts | 22 ++++++++++++---------- webapp/src/viewModel/cardTree.ts | 3 +-- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/webapp/src/blocks/boardView.ts b/webapp/src/blocks/boardView.ts index 789fb55b9..e42847b3e 100644 --- a/webapp/src/blocks/boardView.ts +++ b/webapp/src/blocks/boardView.ts @@ -10,7 +10,7 @@ type ISortOption = { propertyId: '__title' | string, reversed: boolean } interface BoardView extends IBlock { readonly viewType: IViewType - readonly groupById: string + readonly groupById?: string readonly sortOptions: readonly ISortOption[] readonly visiblePropertyIds: readonly string[] readonly visibleOptionIds: readonly string[] @@ -20,7 +20,7 @@ interface BoardView extends IBlock { readonly columnWidths: Readonly> } -class MutableBoardView extends MutableBlock { +class MutableBoardView extends MutableBlock implements BoardView { get viewType(): IViewType { return this.fields.viewType } diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index ba8be87b4..c0616fc30 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -11,6 +11,7 @@ import {OctoListener} from '../octoListener' import {Utils} from '../utils' import {MutableWorkspaceTree, WorkspaceTree} from '../viewModel/workspaceTree' import {IBlock} from '../blocks/block' +import { isReturnStatement } from 'typescript' type Props = { setLanguage: (lang: string) => void @@ -24,18 +25,13 @@ type State = { } export default class BoardPage extends React.Component { - view: BoardView - - updateTitleTimeout: number - updatePropertyLabelTimeout: number - private workspaceListener = new OctoListener() constructor(props: Props) { super(props) const queryString = new URLSearchParams(window.location.search) - const boardId = queryString.get('id') - const viewId = queryString.get('v') + const boardId = queryString.get('id') || '' + const viewId = queryString.get('v') || '' this.state = { boardId, @@ -180,7 +176,7 @@ export default class BoardPage extends React.Component { this.setState({ boardTree, boardId, - viewId: boardTree.activeView.id, + viewId: boardTree.activeView!.id, }) Utils.log(`sync complete: ${boardTree.board.id} (${boardTree.board.title})`) } @@ -220,7 +216,7 @@ export default class BoardPage extends React.Component { } showView(viewId: string, boardId: string = this.state.boardId): void { - if (this.state.boardId === boardId) { + if (this.state.boardTree && this.state.boardId === boardId) { const newBoardTree = this.state.boardTree.mutableCopy() newBoardTree.setActiveView(viewId) this.setState({boardTree: newBoardTree, viewId}) @@ -233,6 +229,11 @@ export default class BoardPage extends React.Component { } setSearchText(text?: string): void { + if (!this.state.boardTree) { + Utils.assertFailure('setSearchText: boardTree') + return + } + const newBoardTree = this.state.boardTree.mutableCopy() newBoardTree.setSearchText(text) this.setState({boardTree: newBoardTree}) diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index 4e05a4a43..1fe0493ca 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -146,10 +146,10 @@ class MutableBoardTree implements BoardTree { this.cards = this.sortCards(this.cards) as MutableCard[] Utils.assert(this.cards !== undefined) - if (this.activeView.groupById) { + if (this.activeView?.groupById) { this.setGroupByProperty(this.activeView.groupById) } else { - Utils.assert(this.activeView.viewType !== 'board') + Utils.assert(this.activeView?.viewType !== 'board') } Utils.assert(this.cards !== undefined) @@ -186,6 +186,10 @@ class MutableBoardTree implements BoardTree { private groupCards() { const {activeView, groupByProperty} = this + if (!activeView || !groupByProperty) { + Utils.assertFailure('groupCards') + return + } const unassignedOptionIds = groupByProperty.options. filter((o) => !activeView.visibleOptionIds.includes(o.id) && !activeView.hiddenOptionIds.includes(o.id)). @@ -221,7 +225,7 @@ class MutableBoardTree implements BoardTree { // Empty group const emptyGroupCards = this.cards.filter((o) => { const optionId = o.properties[groupByProperty.id] - return !optionId || !this.groupByProperty.options.find((option) => option.id === optionId) + return !optionId || !groupByProperty.options.find((option) => option.id === optionId) }) const group: Group = { option: {id: '', value: `No ${groupByProperty.name}`, color: ''}, @@ -264,9 +268,7 @@ class MutableBoardTree implements BoardTree { return cardA.createAt - cardB.createAt } - private manualOrder(cardA: Card, cardB: Card) { - const {activeView} = this - + private manualOrder(activeView: BoardView, cardA: Card, cardB: Card) { const indexA = activeView.cardOrder.indexOf(cardA.id) const indexB = activeView.cardOrder.indexOf(cardB.id) @@ -280,17 +282,17 @@ class MutableBoardTree implements BoardTree { } private sortCards(cards: Card[]): Card[] { - if (!this.activeView) { + const {board, activeView} = this + if (!activeView) { Utils.assertFailure() return cards } - const {board} = this - const {sortOptions} = this.activeView + const {sortOptions} = activeView let sortedCards: Card[] = [] if (sortOptions.length < 1) { Utils.log('Manual sort') - sortedCards = cards.sort((a, b) => this.manualOrder(a, b)) + sortedCards = cards.sort((a, b) => this.manualOrder(activeView, a, b)) } else { sortOptions.forEach((sortOption) => { if (sortOption.propertyId === Constants.titleColumnId) { diff --git a/webapp/src/viewModel/cardTree.ts b/webapp/src/viewModel/cardTree.ts index 5ddafd20c..43eaf77dc 100644 --- a/webapp/src/viewModel/cardTree.ts +++ b/webapp/src/viewModel/cardTree.ts @@ -5,7 +5,6 @@ import {IOrderedBlock} from '../blocks/orderedBlock' import octoClient from '../octoClient' import {IBlock, MutableBlock} from '../blocks/block' import {OctoUtils} from '../octoUtils' -import {Utils} from '../utils' interface CardTree { readonly card: Card @@ -17,7 +16,7 @@ interface CardTree { } class MutableCardTree implements CardTree { - card: MutableCard + card!: MutableCard comments: IBlock[] = [] contents: IOrderedBlock[] = [] From d405f46114986bf3cead7cc77efed0f1ca3e4dee Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 12:33:05 -0800 Subject: [PATCH 35/61] strictNullChecks: Make boardTree, activeView and block.title non-optional --- webapp/src/blocks/block.ts | 13 +++++---- webapp/src/components/boardCard.tsx | 6 ++--- webapp/src/components/boardComponent.tsx | 16 ++--------- .../src/components/propertyValueElement.tsx | 4 +-- webapp/src/components/sidebar.tsx | 7 +++-- webapp/src/components/tableComponent.tsx | 20 +++----------- webapp/src/components/tableRow.tsx | 2 +- webapp/src/components/viewHeader.tsx | 10 +++---- webapp/src/components/viewMenu.tsx | 2 +- webapp/src/components/workspaceComponent.tsx | 6 ++--- webapp/src/mutator.ts | 4 +-- webapp/src/pages/boardPage.tsx | 4 +-- webapp/src/viewModel/boardTree.ts | 27 ++++++++++++------- 13 files changed, 48 insertions(+), 73 deletions(-) diff --git a/webapp/src/blocks/block.ts b/webapp/src/blocks/block.ts index 0b4b2a5fe..15f9cc45b 100644 --- a/webapp/src/blocks/block.ts +++ b/webapp/src/blocks/block.ts @@ -10,7 +10,7 @@ interface IBlock { readonly schema: number readonly type: BlockTypes - readonly title?: string + readonly title: string readonly fields: Readonly> readonly createAt: number @@ -24,7 +24,7 @@ interface IMutableBlock extends IBlock { schema: number type: BlockTypes - title?: string + title: string fields: Record createAt: number @@ -56,18 +56,17 @@ class MutableBlock implements IMutableBlock { } constructor(block: any = {}) { - const now = Date.now() - this.id = block.id || Utils.createGuid() this.schema = 1 - this.parentId = block.parentId - this.type = block.type + this.parentId = block.parentId || '' + this.type = block.type || '' // Shallow copy here. Derived classes must make deep copies of their known properties in their constructors. this.fields = block.fields ? {...block.fields} : {} - this.title = block.title + this.title = block.title || '' + const now = Date.now() this.createAt = block.createAt || now this.updateAt = block.updateAt || now this.deleteAt = block.deleteAt || 0 diff --git a/webapp/src/components/boardCard.tsx b/webapp/src/components/boardCard.tsx index f4cc3cb3d..7e3bd63e0 100644 --- a/webapp/src/components/boardCard.tsx +++ b/webapp/src/components/boardCard.tsx @@ -25,9 +25,9 @@ type BoardCardProps = { isSelected: boolean isDropZone?: boolean onClick?: (e: React.MouseEvent) => void - onDragStart?: (e: React.DragEvent) => void - onDragEnd?: (e: React.DragEvent) => void - onDrop?: (e: React.DragEvent) => void + onDragStart: (e: React.DragEvent) => void + onDragEnd: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent) => void intl: IntlShape } diff --git a/webapp/src/components/boardComponent.tsx b/webapp/src/components/boardComponent.tsx index 0a71a80df..3084b8ef0 100644 --- a/webapp/src/components/boardComponent.tsx +++ b/webapp/src/components/boardComponent.tsx @@ -35,7 +35,7 @@ import ViewTitle from './viewTitle' import './boardComponent.scss' type Props = { - boardTree?: BoardTree + boardTree: BoardTree showView: (id: string) => void setSearchText: (text: string) => void intl: IntlShape @@ -85,7 +85,7 @@ class BoardComponent extends React.Component { constructor(props: Props) { super(props) this.state = { - isSearching: Boolean(this.props.boardTree?.getSearchText()), + isSearching: Boolean(this.props.boardTree.getSearchText()), viewMenu: false, selectedCardIds: [], showFilter: false, @@ -104,18 +104,6 @@ class BoardComponent extends React.Component { render(): JSX.Element { const {boardTree, showView} = this.props - - if (!boardTree || !boardTree.board) { - return ( -
- -
- ) - } - const propertyValues = boardTree.groupByProperty?.options || [] Utils.log(`${propertyValues.length} propertyValues`) diff --git a/webapp/src/components/propertyValueElement.tsx b/webapp/src/components/propertyValueElement.tsx index 86a005db6..c55d215ab 100644 --- a/webapp/src/components/propertyValueElement.tsx +++ b/webapp/src/components/propertyValueElement.tsx @@ -56,7 +56,7 @@ export default class PropertyValueElement extends React.Component className += ' empty' } - if (readOnly) { + if (readOnly || !boardTree) { return (
value, color: 'propColorDefault', } - await mutator.insertPropertyOption(this.props.boardTree, propertyTemplate, option, 'add property option') + await mutator.insertPropertyOption(boardTree, propertyTemplate, option, 'add property option') mutator.changePropertyValue(card, propertyTemplate.id, option.id) } } diff --git a/webapp/src/components/sidebar.tsx b/webapp/src/components/sidebar.tsx index b96046a0f..32c828913 100644 --- a/webapp/src/components/sidebar.tsx +++ b/webapp/src/components/sidebar.tsx @@ -6,7 +6,6 @@ import {injectIntl, IntlShape, FormattedMessage} from 'react-intl' import {Archiver} from '../archiver' import {mattermostTheme, darkTheme, lightTheme, setTheme} from '../theme' import {Board, MutableBoard} from '../blocks/board' -import {BoardTree} from '../viewModel/boardTree' import mutator from '../mutator' import Menu from '../widgets/menu' import MenuWrapper from '../widgets/menuWrapper' @@ -28,7 +27,7 @@ type Props = { showBoard: (id: string) => void showView: (id: string, boardId?: string) => void workspaceTree: WorkspaceTree, - boardTree?: BoardTree, + activeBoardId?: string setLanguage: (lang: string) => void, intl: IntlShape } @@ -273,9 +272,9 @@ class Sidebar extends React.Component { } private addBoardClicked = async () => { - const {boardTree, showBoard} = this.props + const {showBoard} = this.props - const oldBoardId = boardTree?.board?.id + const oldBoardId = this.props.activeBoardId const board = new MutableBoard() await mutator.insertBlock( board, diff --git a/webapp/src/components/tableComponent.tsx b/webapp/src/components/tableComponent.tsx index 08f31de88..9b5209326 100644 --- a/webapp/src/components/tableComponent.tsx +++ b/webapp/src/components/tableComponent.tsx @@ -30,7 +30,7 @@ import {IBlock} from '../blocks/block' import {MutableCardTree} from '../viewModel/cardTree' type Props = { - boardTree?: BoardTree + boardTree: BoardTree showView: (id: string) => void setSearchText: (text: string) => void } @@ -40,9 +40,9 @@ type State = { } class TableComponent extends React.Component { - private draggedHeaderTemplate: IPropertyTemplate + private draggedHeaderTemplate?: IPropertyTemplate private cardIdToRowMap = new Map>() - private cardIdToFocusOnRender: string + private cardIdToFocusOnRender?: string state: State = {} shouldComponentUpdate(): boolean { @@ -51,18 +51,6 @@ class TableComponent extends React.Component { render(): JSX.Element { const {boardTree, showView} = this.props - - if (!boardTree || !boardTree.board) { - return ( -
- -
- ) - } - const {board, cards, activeView} = boardTree const titleRef = React.createRef() @@ -300,7 +288,7 @@ class TableComponent extends React.Component { } private columnWidth(templateId: string): number { - return Math.max(Constants.minColumnWidth, this.props.boardTree?.activeView?.columnWidths[templateId] || 0) + return Math.max(Constants.minColumnWidth, this.props.boardTree.activeView.columnWidths[templateId] || 0) } private addCardAndShow = () => { diff --git a/webapp/src/components/tableRow.tsx b/webapp/src/components/tableRow.tsx index 41d7c4aeb..21088ce70 100644 --- a/webapp/src/components/tableRow.tsx +++ b/webapp/src/components/tableRow.tsx @@ -115,7 +115,7 @@ class TableRow extends React.Component { } private columnWidth(templateId: string): number { - return Math.max(Constants.minColumnWidth, this.props.boardTree?.activeView?.columnWidths[templateId] || 0) + return Math.max(Constants.minColumnWidth, this.props.boardTree.activeView.columnWidths[templateId] || 0) } focusOnTitle(): void { diff --git a/webapp/src/components/viewHeader.tsx b/webapp/src/components/viewHeader.tsx index 35240dc76..0e93ad488 100644 --- a/webapp/src/components/viewHeader.tsx +++ b/webapp/src/components/viewHeader.tsx @@ -34,7 +34,7 @@ import {Constants} from '../constants' import DeleteIcon from '../widgets/icons/delete' type Props = { - boardTree?: BoardTree + boardTree: BoardTree showView: (id: string) => void setSearchText: (text: string) => void addCard: () => void @@ -59,7 +59,7 @@ class ViewHeader extends React.Component { constructor(props: Props) { super(props) - this.state = {isSearching: Boolean(this.props.boardTree?.getSearchText()), showFilter: false} + this.state = {isSearching: Boolean(this.props.boardTree.getSearchText()), showFilter: false} } componentDidUpdate(prevPros: Props, prevState: State): void { @@ -93,7 +93,7 @@ class ViewHeader extends React.Component { const {boardTree} = this.props const {board, activeView} = boardTree - const startCount = boardTree?.cards?.length + const startCount = boardTree.cards.length let optionIndex = 0 await mutator.performAsUndoGroup(async () => { @@ -117,10 +117,6 @@ class ViewHeader extends React.Component { private async testDistributeCards() { const {boardTree} = this.props - if (!boardTree) { - return - } - await mutator.performAsUndoGroup(async () => { let optionIndex = 0 for (const card of boardTree.cards) { diff --git a/webapp/src/components/viewMenu.tsx b/webapp/src/components/viewMenu.tsx index f15b36ef2..c7702c1e4 100644 --- a/webapp/src/components/viewMenu.tsx +++ b/webapp/src/components/viewMenu.tsx @@ -11,7 +11,7 @@ import Menu from '../widgets/menu' import {Constants} from '../constants' type Props = { - boardTree?: BoardTree + boardTree: BoardTree board: Board, showView: (id: string) => void } diff --git a/webapp/src/components/workspaceComponent.tsx b/webapp/src/components/workspaceComponent.tsx index 49075ded8..03b11df0f 100644 --- a/webapp/src/components/workspaceComponent.tsx +++ b/webapp/src/components/workspaceComponent.tsx @@ -32,7 +32,7 @@ class WorkspaceComponent extends React.Component { showBoard={showBoard} showView={showView} workspaceTree={workspaceTree} - boardTree={boardTree} + activeBoardId={boardTree?.board.id} setLanguage={setLanguage} /> {this.mainComponent()} @@ -45,11 +45,11 @@ class WorkspaceComponent extends React.Component { const {boardTree, setSearchText, showView} = this.props const {activeView} = boardTree || {} - if (!activeView) { + if (!boardTree || !activeView) { return
} - switch (activeView?.viewType) { + switch (activeView.viewType) { case 'board': { return ( o.id === newCardId)! - newCard.title = `Copy of ${newCard.title || ''}` + newCard.title = `Copy of ${newCard.title}` await this.insertBlocks( newBlocks, description, @@ -515,7 +515,7 @@ class Mutator { Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`) const newBoardId = idMap[boardId] const newBoard = newBlocks.find((o) => o.id === newBoardId)! - newBoard.title = `Copy of ${newBoard.title || ''}` + newBoard.title = `Copy of ${newBoard.title}` await this.insertBlocks( newBlocks, description, diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index c0616fc30..d886a5fb1 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. import React from 'react' -import {BoardView} from '../blocks/boardView' import {BoardTree, MutableBoardTree} from '../viewModel/boardTree' import {WorkspaceComponent} from '../components/workspaceComponent' import {sendFlashMessage} from '../components/flashMessages' @@ -11,7 +10,6 @@ import {OctoListener} from '../octoListener' import {Utils} from '../utils' import {MutableWorkspaceTree, WorkspaceTree} from '../viewModel/workspaceTree' import {IBlock} from '../blocks/block' -import { isReturnStatement } from 'typescript' type Props = { setLanguage: (lang: string) => void @@ -194,7 +192,7 @@ export default class BoardPage extends React.Component { const newBoardTree = boardTree ? boardTree.mutableCopy() : new MutableBoardTree(this.state.boardId) if (newBoardTree.incrementalUpdate(blocks)) { - newBoardTree.setActiveView(viewId) + newBoardTree.setActiveView(this.state.viewId) newState = {...newState, boardTree: newBoardTree} } diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index 1fe0493ca..6b4cd8948 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -25,7 +25,7 @@ interface BoardTree { readonly hiddenGroups: readonly Group[] readonly allBlocks: readonly IBlock[] - readonly activeView?: BoardView + readonly activeView: BoardView readonly groupByProperty?: IPropertyTemplate getSearchText(): string | undefined @@ -42,7 +42,7 @@ class MutableBoardTree implements BoardTree { visibleGroups: Group[] = [] hiddenGroups: Group[] = [] - activeView?: MutableBoardView + activeView!: MutableBoardView groupByProperty?: IPropertyTemplate private rawBlocks: IBlock[] = [] @@ -67,6 +67,7 @@ class MutableBoardTree implements BoardTree { } this.rawBlocks = OctoUtils.mergeBlocks(this.rawBlocks, relevantBlocks) this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) + return true } @@ -109,16 +110,22 @@ class MutableBoardTree implements BoardTree { didChange = true } + if (!this.activeView) { + this.activeView = this.views[0] + } + return didChange } setActiveView(viewId: string) { - this.activeView = this.views.find((o) => o.id === viewId) - if (!this.activeView) { + let view = this.views.find((o) => o.id === viewId) + if (!view) { Utils.logError(`Cannot find BoardView: ${viewId}`) - this.activeView = this.views[0] + view = this.views[0] } + this.activeView = view + // Fix missing group by (e.g. for new views) if (this.activeView.viewType === 'board' && !this.activeView.groupById) { this.activeView.groupById = this.board.cardProperties.find((o) => o.type === 'select')?.id @@ -146,10 +153,10 @@ class MutableBoardTree implements BoardTree { this.cards = this.sortCards(this.cards) as MutableCard[] Utils.assert(this.cards !== undefined) - if (this.activeView?.groupById) { + if (this.activeView.groupById) { this.setGroupByProperty(this.activeView.groupById) } else { - Utils.assert(this.activeView?.viewType !== 'board') + Utils.assert(this.activeView.viewType !== 'board') } Utils.assert(this.cards !== undefined) @@ -240,7 +247,7 @@ class MutableBoardTree implements BoardTree { private filterCards(cards: MutableCard[]): Card[] { const {board} = this - const filterGroup = this.activeView?.filter + const filterGroup = this.activeView.filter if (!filterGroup) { return cards.slice() } @@ -249,8 +256,8 @@ class MutableBoardTree implements BoardTree { } private titleOrCreatedOrder(cardA: Card, cardB: Card) { - const aValue = cardA.title || '' - const bValue = cardB.title || '' + const aValue = cardA.title + const bValue = cardB.title if (aValue && bValue) { return aValue.localeCompare(bValue) From a463f6e1be9b65fa58ee75788ad6ee56364b61f8 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 13:24:17 -0800 Subject: [PATCH 36/61] Create board view with new boards --- webapp/i18n/en.json | 16 +++++++++++----- webapp/src/components/sidebar.tsx | 13 +++++++++---- webapp/src/components/viewMenu.tsx | 15 ++++++++++----- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 24efc34e2..028323185 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -6,7 +6,6 @@ "BoardComponent.delete": "Delete", "BoardComponent.hidden-columns": "Hidden Columns", "BoardComponent.hide": "Hide", - "BoardComponent.loading": "Loading...", "BoardComponent.neww": "+ New", "BoardComponent.no-property": "No {property}", "BoardComponent.no-property-title": "Items with an empty {property} property will go here. This column cannot be removed.", @@ -16,10 +15,8 @@ "CardDetail.add-property": "+ Add a property", "CardDetail.image": "Image", "CardDetail.new-comment-placeholder": "Add a comment...", - "CardDetail.pick-icon": "Pick Icon", - "CardDetail.random-icon": "Random", - "CardDetail.remove-icon": "Remove Icon", "CardDetail.text": "Text", + "CardDialog.editing-template": "You're editing a template", "Comment.delete": "Delete", "CommentsList.send": "Send", "Filter.includes": "includes", @@ -31,6 +28,7 @@ "Sidebar.add-board": "+ Add Board", "Sidebar.dark-theme": "Dark Theme", "Sidebar.delete-board": "Delete Board", + "Sidebar.duplicate-board": "Duplicate Board", "Sidebar.english": "English", "Sidebar.export-archive": "Export Archive", "Sidebar.import-archive": "Import Archive", @@ -44,7 +42,6 @@ "Sidebar.untitled-board": "(Untitled Board)", "Sidebar.untitled-view": "(Untitled View)", "TableComponent.add-icon": "Add Icon", - "TableComponent.loading": "Loading...", "TableComponent.name": "Name", "TableComponent.plus-new": "+ New", "TableHeaderMenu.delete": "Delete", @@ -55,6 +52,12 @@ "TableHeaderMenu.sort-ascending": "Sort ascending", "TableHeaderMenu.sort-descending": "Sort descending", "TableRow.open": "Open", + "View.NewBoardTitle": "Board View", + "View.NewTableTitle": "Table View", + "ViewHeader.add-template": "+ New template", + "ViewHeader.delete-template": "Delete", + "ViewHeader.edit-template": "Edit", + "ViewHeader.empty-card": "Empty card", "ViewHeader.export-board-archive": "Export Board Archive", "ViewHeader.export-csv": "Export to CSV", "ViewHeader.filter": "Filter", @@ -63,10 +66,13 @@ "ViewHeader.properties": "Properties", "ViewHeader.search": "Search", "ViewHeader.search-text": "Search text", + "ViewHeader.select-a-template": "Select a template", "ViewHeader.sort": "Sort", "ViewHeader.test-add-100-cards": "TEST: Add 100 cards", "ViewHeader.test-add-1000-cards": "TEST: Add 1,000 cards", + "ViewHeader.test-distribute-cards": "TEST: Distribute cards", "ViewHeader.test-randomize-icons": "TEST: Randomize icons", + "ViewHeader.untitled": "Untitled", "ViewTitle.pick-icon": "Pick Icon", "ViewTitle.random-icon": "Random", "ViewTitle.remove-icon": "Remove Icon", diff --git a/webapp/src/components/sidebar.tsx b/webapp/src/components/sidebar.tsx index 32c828913..6377e7a66 100644 --- a/webapp/src/components/sidebar.tsx +++ b/webapp/src/components/sidebar.tsx @@ -19,7 +19,7 @@ import DotIcon from '../widgets/icons/dot' import IconButton from '../widgets/buttons/iconButton' import Button from '../widgets/buttons/button' import {WorkspaceTree} from '../viewModel/workspaceTree' -import {BoardView} from '../blocks/boardView' +import {BoardView, MutableBoardView} from '../blocks/boardView' import './sidebar.scss' @@ -272,12 +272,17 @@ class Sidebar extends React.Component { } private addBoardClicked = async () => { - const {showBoard} = this.props + const {showBoard, intl} = this.props const oldBoardId = this.props.activeBoardId const board = new MutableBoard() - await mutator.insertBlock( - board, + const view = new MutableBoardView() + view.viewType = 'board' + view.parentId = board.id + view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'}) + + await mutator.insertBlocks( + [board, view], 'add board', async () => { showBoard(board.id) diff --git a/webapp/src/components/viewMenu.tsx b/webapp/src/components/viewMenu.tsx index c7702c1e4..5580cbfee 100644 --- a/webapp/src/components/viewMenu.tsx +++ b/webapp/src/components/viewMenu.tsx @@ -2,6 +2,8 @@ // See LICENSE.txt for license information. import React from 'react' +import {injectIntl, IntlShape} from 'react-intl' + import {Board} from '../blocks/board' import {MutableBoardView} from '../blocks/boardView' import {BoardTree} from '../viewModel/boardTree' @@ -14,9 +16,10 @@ type Props = { boardTree: BoardTree board: Board, showView: (id: string) => void + intl: IntlShape } -export default class ViewMenu extends React.Component { +export class ViewMenu extends React.Component { handleDeleteView = async () => { const {boardTree, showView} = this.props Utils.log('deleteView') @@ -34,10 +37,10 @@ export default class ViewMenu extends React.Component { } handleAddViewBoard = async () => { - const {board, boardTree, showView} = this.props + const {board, boardTree, showView, intl} = this.props Utils.log('addview-board') const view = new MutableBoardView() - view.title = 'Board View' + view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board View'}) view.viewType = 'board' view.parentId = board.id @@ -55,11 +58,11 @@ export default class ViewMenu extends React.Component { } handleAddViewTable = async () => { - const {board, boardTree, showView} = this.props + const {board, boardTree, showView, intl} = this.props Utils.log('addview-table') const view = new MutableBoardView() - view.title = 'Table View' + view.title = intl.formatMessage({id: 'View.NewTableTitle', defaultMessage: 'Table View'}) view.viewType = 'table' view.parentId = board.id view.visiblePropertyIds = board.cardProperties.map((o) => o.id) @@ -115,3 +118,5 @@ export default class ViewMenu extends React.Component { ) } } + +export default injectIntl(ViewMenu) From dec0d0ae29a39d100861aeb83fb41e7f1a9e6cca Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 13:29:04 -0800 Subject: [PATCH 37/61] Sort boards, views, and templates by title --- webapp/src/viewModel/boardTree.ts | 6 ++++-- webapp/src/viewModel/workspaceTree.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index 6b4cd8948..e8de4741b 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -73,9 +73,11 @@ class MutableBoardTree implements BoardTree { private rebuild(blocks: IMutableBlock[]) { this.board = blocks.find((block) => block.type === 'board') as MutableBoard - this.views = blocks.filter((block) => block.type === 'view') as MutableBoardView[] + this.views = blocks.filter((block) => block.type === 'view') + .sort((a, b) => a.title.localeCompare(b.title)) as MutableBoardView[] this.allCards = blocks.filter((block) => block.type === 'card' && !(block as Card).isTemplate) as MutableCard[] - this.cardTemplates = blocks.filter((block) => block.type === 'card' && (block as Card).isTemplate) as MutableCard[] + this.cardTemplates = blocks.filter((block) => block.type === 'card' && (block as Card).isTemplate) + .sort((a, b) => a.title.localeCompare(b.title)) as MutableCard[] this.cards = [] this.ensureMinimumSchema() diff --git a/webapp/src/viewModel/workspaceTree.ts b/webapp/src/viewModel/workspaceTree.ts index dd590e277..778efc1d9 100644 --- a/webapp/src/viewModel/workspaceTree.ts +++ b/webapp/src/viewModel/workspaceTree.ts @@ -37,8 +37,10 @@ class MutableWorkspaceTree { } private rebuild(blocks: IBlock[]) { - this.boards = blocks.filter((block) => block.type === 'board') as Board[] - this.views = blocks.filter((block) => block.type === 'view') as BoardView[] + this.boards = blocks.filter((block) => block.type === 'board') + .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[] } mutableCopy(): MutableWorkspaceTree { From 0b3fa571a7a8b8e2da421391d90d64a3b63b6b19 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 14:06:02 -0800 Subject: [PATCH 38/61] strictNullChecks --- webapp/src/cardFilter.ts | 38 +++++++--- webapp/src/components/boardComponent.tsx | 77 ++++++++++++-------- webapp/src/components/sidebar.tsx | 2 +- webapp/src/components/tableComponent.tsx | 10 +-- webapp/src/components/tableRow.tsx | 2 +- webapp/src/components/viewHeader.tsx | 10 +-- webapp/src/components/viewMenu.tsx | 9 ++- webapp/src/components/workspaceComponent.tsx | 2 +- webapp/src/widgets/valueSelector.tsx | 12 +-- 9 files changed, 98 insertions(+), 64 deletions(-) diff --git a/webapp/src/cardFilter.ts b/webapp/src/cardFilter.ts index 9de4bf566..2584bdb87 100644 --- a/webapp/src/cardFilter.ts +++ b/webapp/src/cardFilter.ts @@ -71,8 +71,12 @@ class CardFilter { return true } - static propertiesThatMeetFilterGroup(filterGroup: FilterGroup, templates: readonly IPropertyTemplate[]): Record { + static propertiesThatMeetFilterGroup(filterGroup: FilterGroup | undefined, templates: readonly IPropertyTemplate[]): Record { // TODO: Handle filter groups + if (!filterGroup) { + return {} + } + const filters = filterGroup.filters.filter((o) => !FilterGroup.isAnInstanceOf(o)) if (filters.length < 1) { return {} @@ -82,19 +86,30 @@ class CardFilter { // Just need to meet the first clause const property = this.propertyThatMeetsFilterClause(filters[0] as FilterClause, templates) const result: Record = {} - result[property.id] = property.value + if (property.value) { + result[property.id] = property.value + } + return result + } else { + // And: Need to meet all clauses + const result: Record = {} + filters.forEach((filterClause) => { + const property = this.propertyThatMeetsFilterClause(filterClause as FilterClause, templates) + if (property.value) { + result[property.id] = property.value + } + }) return result } - const result: Record = {} - filters.forEach((filterClause) => { - const p = this.propertyThatMeetsFilterClause(filterClause as FilterClause, templates) - result[p.id] = p.value - }) - return result } static propertyThatMeetsFilterClause(filterClause: FilterClause, templates: readonly IPropertyTemplate[]): { id: string, value?: string } { const template = templates.find((o) => o.id === filterClause.propertyId) + if (!template) { + Utils.assertFailure(`propertyThatMeetsFilterClause. Cannot find template: ${filterClause.propertyId}`) + return {id: filterClause.propertyId} + } + switch (filterClause.condition) { case 'includes': { if (filterClause.values.length < 1) { @@ -108,7 +123,12 @@ class CardFilter { } if (template.type === 'select') { const option = template.options.find((o) => !filterClause.values.includes(o.id)) - return {id: filterClause.propertyId, value: option.id} + if (option) { + return {id: filterClause.propertyId, value: option.id} + } else { + // No other options exist + return {id: filterClause.propertyId} + } } // TODO: Handle non-select types diff --git a/webapp/src/components/boardComponent.tsx b/webapp/src/components/boardComponent.tsx index 3084b8ef0..52925a4c4 100644 --- a/webapp/src/components/boardComponent.tsx +++ b/webapp/src/components/boardComponent.tsx @@ -37,7 +37,7 @@ import './boardComponent.scss' type Props = { boardTree: BoardTree showView: (id: string) => void - setSearchText: (text: string) => void + setSearchText: (text?: string) => void intl: IntlShape } @@ -51,7 +51,7 @@ type State = { class BoardComponent extends React.Component { private draggedCards: Card[] = [] - private draggedHeaderOption: IPropertyOption + private draggedHeaderOption?: IPropertyOption private backgroundRef = React.createRef() private searchFieldRef = React.createRef() @@ -98,13 +98,20 @@ class BoardComponent extends React.Component { componentDidUpdate(prevPros: Props, prevState: State): void { if (this.state.isSearching && !prevState.isSearching) { - this.searchFieldRef.current.focus() + this.searchFieldRef.current?.focus() } } render(): JSX.Element { const {boardTree, showView} = this.props - const propertyValues = boardTree.groupByProperty?.options || [] + const {groupByProperty} = boardTree + + if (!groupByProperty) { + Utils.assertFailure('Board views must have groupByProperty set') + return
+ } + + const propertyValues = groupByProperty.options || [] Utils.log(`${propertyValues.length} propertyValues`) const {board, activeView, visibleGroups, hiddenGroups} = boardTree @@ -232,7 +239,11 @@ class BoardComponent extends React.Component { this.cardClicked(e, card) }} onDragStart={() => { - this.draggedCards = this.state.selectedCardIds.includes(card.id) ? this.state.selectedCardIds.map((id) => boardTree.allCards.find((o) => o.id === id)) : [card] + if (this.state.selectedCardIds.includes(card.id)) { + this.draggedCards = this.state.selectedCardIds.map((id) => boardTree.allCards.find((o) => o.id === id)!) + } else { + this.draggedCards = [card] + } }} onDragEnd={() => { this.draggedCards = [] @@ -269,19 +280,19 @@ class BoardComponent extends React.Component { }} 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) }} @@ -291,13 +302,13 @@ class BoardComponent extends React.Component { title={intl.formatMessage({ id: 'BoardComponent.no-property-title', defaultMessage: 'Items with an empty {property} property will go here. This column cannot be removed.', - }, {property: boardTree.groupByProperty?.name})} + }, {property: boardTree.groupByProperty!.name})} >
@@ -338,19 +349,19 @@ class BoardComponent extends React.Component { }} 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) }} @@ -379,7 +390,7 @@ class BoardComponent extends React.Component { id='delete' icon={} name={intl.formatMessage({id: 'BoardComponent.delete', defaultMessage: 'Delete'})} - onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty, group.option)} + onClick={() => mutator.deletePropertyOption(boardTree, boardTree.groupByProperty!, group.option)} /> {Constants.menuColors.map((color) => ( @@ -387,7 +398,7 @@ class BoardComponent extends React.Component { key={color.id} id={color.id} name={color.name} - onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty, group.option, color.id)} + onClick={() => mutator.changePropertyOptionColor(boardTree.board, boardTree.groupByProperty!, group.option, color.id)} /> ))} @@ -414,25 +425,25 @@ class BoardComponent extends React.Component { 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 @@ -538,7 +549,7 @@ class BoardComponent extends React.Component { private async propertyNameChanged(option: IPropertyOption, text: string): Promise { const {boardTree} = this.props - await mutator.changePropertyOptionValue(boardTree, boardTree.groupByProperty, option, text) + await mutator.changePropertyOptionValue(boardTree, boardTree.groupByProperty!, option, text) } private cardClicked(e: React.MouseEvent, card: Card): void { @@ -585,8 +596,7 @@ class BoardComponent extends React.Component { color: 'propColorDefault', } - Utils.assert(boardTree.groupByProperty) - await mutator.insertPropertyOption(boardTree, boardTree.groupByProperty, option, 'add group') + await mutator.insertPropertyOption(boardTree, boardTree.groupByProperty!, option, 'add group') } private async onDropToColumn(option: IPropertyOption) { @@ -601,15 +611,14 @@ class BoardComponent extends React.Component { const description = draggedCards.length > 1 ? `drag ${draggedCards.length} cards` : 'drag card' for (const draggedCard of draggedCards) { Utils.log(`ondrop. Card: ${draggedCard.title}, column: ${optionId}`) - const oldValue = draggedCard.properties[boardTree.groupByProperty.id] + const oldValue = draggedCard.properties[boardTree.groupByProperty!.id] if (optionId !== oldValue) { - await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty.id, optionId, description) + await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description) } } }) } else if (draggedHeaderOption) { Utils.log(`ondrop. Header option: ${draggedHeaderOption.value}, column: ${option?.value}`) - Utils.assertValue(boardTree.groupByProperty) // Move option to new index const visibleOptionIds = boardTree.visibleGroups.map((o) => o.option.id) @@ -629,7 +638,7 @@ class BoardComponent extends React.Component { const {boardTree} = this.props const {activeView} = boardTree const {draggedCards} = this - const optionId = card.properties[activeView.groupById] + const optionId = card.properties[activeView.groupById!] if (draggedCards.length < 1 || draggedCards.includes(card)) { return @@ -644,7 +653,7 @@ class BoardComponent extends React.Component { const isDraggingDown = cardOrder.indexOf(firstDraggedCard.id) <= cardOrder.indexOf(card.id) cardOrder = cardOrder.filter((id) => !draggedCardIds.includes(id)) let destIndex = cardOrder.indexOf(card.id) - if (firstDraggedCard.properties[boardTree.groupByProperty.id] === optionId && isDraggingDown) { + if (firstDraggedCard.properties[boardTree.groupByProperty!.id] === optionId && isDraggingDown) { // If the cards are in the same column and dragging down, drop after the target card destIndex += 1 } @@ -654,9 +663,9 @@ class BoardComponent extends React.Component { // Update properties of dragged cards for (const draggedCard of draggedCards) { Utils.log(`draggedCard: ${draggedCard.title}, column: ${optionId}`) - const oldOptionId = draggedCard.properties[boardTree.groupByProperty.id] + const oldOptionId = draggedCard.properties[boardTree.groupByProperty!.id] if (optionId !== oldOptionId) { - await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty.id, optionId, description) + await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description) } } @@ -673,7 +682,11 @@ class BoardComponent extends React.Component { mutator.performAsUndoGroup(async () => { for (const cardId of selectedCardIds) { const card = this.props.boardTree.allCards.find((o) => o.id === cardId) - mutator.deleteBlock(card, selectedCardIds.length > 1 ? `delete ${selectedCardIds.length} cards` : 'delete card') + if (card) { + mutator.deleteBlock(card, selectedCardIds.length > 1 ? `delete ${selectedCardIds.length} cards` : 'delete card') + } else { + Utils.assertFailure(`Selected card not found: ${cardId}`) + } } }) diff --git a/webapp/src/components/sidebar.tsx b/webapp/src/components/sidebar.tsx index 6377e7a66..8bc2ea3d6 100644 --- a/webapp/src/components/sidebar.tsx +++ b/webapp/src/components/sidebar.tsx @@ -123,7 +123,7 @@ class Sidebar extends React.Component { name={intl.formatMessage({id: 'Sidebar.delete-board', defaultMessage: 'Delete Board'})} icon={} onClick={async () => { - const nextBoardId = boards.length > 1 ? boards.find((o) => o.id !== board.id).id : undefined + const nextBoardId = boards.length > 1 ? boards.find((o) => o.id !== board.id)?.id : undefined mutator.deleteBlock( board, 'delete block', diff --git a/webapp/src/components/tableComponent.tsx b/webapp/src/components/tableComponent.tsx index 9b5209326..1f7396261 100644 --- a/webapp/src/components/tableComponent.tsx +++ b/webapp/src/components/tableComponent.tsx @@ -32,7 +32,7 @@ import {MutableCardTree} from '../viewModel/cardTree' type Props = { boardTree: BoardTree showView: (id: string) => void - setSearchText: (text: string) => void + setSearchText: (text?: string) => void } type State = { @@ -130,13 +130,13 @@ class TableComponent extends React.Component { onDrag={(offset) => { const originalWidth = this.columnWidth(Constants.titleColumnId) const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset) - titleRef.current.style.width = `${newWidth}px` + 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` + titleRef.current!.style!.width = `${newWidth}px` const columnWidths = {...activeView.columnWidths} if (newWidth !== columnWidths[Constants.titleColumnId]) { @@ -208,13 +208,13 @@ class TableComponent extends React.Component { onDrag={(offset) => { const originalWidth = this.columnWidth(template.id) const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset) - headerRef.current.style.width = `${newWidth}px` + 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` + headerRef.current!.style.width = `${newWidth}px` const columnWidths = {...activeView.columnWidths} if (newWidth !== columnWidths[template.id]) { diff --git a/webapp/src/components/tableRow.tsx b/webapp/src/components/tableRow.tsx index 21088ce70..d9267f67d 100644 --- a/webapp/src/components/tableRow.tsx +++ b/webapp/src/components/tableRow.tsx @@ -42,7 +42,7 @@ class TableRow extends React.Component { componentDidMount(): void { if (this.props.focusOnMount) { - setTimeout(() => this.titleRef.current.focus(), 10) + setTimeout(() => this.titleRef.current!.focus(), 10) } } diff --git a/webapp/src/components/viewHeader.tsx b/webapp/src/components/viewHeader.tsx index 0e93ad488..ab1e3afc4 100644 --- a/webapp/src/components/viewHeader.tsx +++ b/webapp/src/components/viewHeader.tsx @@ -36,7 +36,7 @@ import DeleteIcon from '../widgets/icons/delete' type Props = { boardTree: BoardTree showView: (id: string) => void - setSearchText: (text: string) => void + setSearchText: (text?: string) => void addCard: () => void addCardFromTemplate: (cardTemplateId?: string) => void addCardTemplate: () => void @@ -64,7 +64,7 @@ class ViewHeader extends React.Component { componentDidUpdate(prevPros: Props, prevState: State): void { if (this.state.isSearching && !prevState.isSearching) { - this.searchFieldRef.current.focus() + this.searchFieldRef.current!.focus() } } @@ -78,7 +78,7 @@ class ViewHeader extends React.Component { private onSearchKeyDown = (e: React.KeyboardEvent) => { if (e.keyCode === 27) { // ESC: Clear search - this.searchFieldRef.current.text = '' + this.searchFieldRef.current!.text = '' this.setState({isSearching: false}) this.props.setSearchText(undefined) e.preventDefault() @@ -185,10 +185,6 @@ class ViewHeader extends React.Component { name={option.name} isOn={activeView.visiblePropertyIds.includes(option.id)} onClick={(propertyId: string) => { - const property = boardTree.board.cardProperties.find((o: IPropertyTemplate) => o.id === propertyId) - Utils.assertValue(property) - Utils.log(`Toggle property ${property.name}`) - let newVisiblePropertyIds = [] if (activeView.visiblePropertyIds.includes(propertyId)) { newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o: string) => o !== propertyId) diff --git a/webapp/src/components/viewMenu.tsx b/webapp/src/components/viewMenu.tsx index 5580cbfee..a3679d1e8 100644 --- a/webapp/src/components/viewMenu.tsx +++ b/webapp/src/components/viewMenu.tsx @@ -26,14 +26,19 @@ export class ViewMenu extends React.Component { const view = boardTree.activeView const nextView = boardTree.views.find((o) => o !== view) await mutator.deleteBlock(view, 'delete view') - showView(nextView.id) + if (nextView) { + showView(nextView.id) + } } handleViewClick = (id: string) => { const {boardTree, showView} = this.props Utils.log('view ' + id) const view = boardTree.views.find((o) => o.id === id) - showView(view.id) + Utils.assert(view, `view not found: ${id}`) + if (view) { + showView(view.id) + } } handleAddViewBoard = async () => { diff --git a/webapp/src/components/workspaceComponent.tsx b/webapp/src/components/workspaceComponent.tsx index 03b11df0f..e055a336a 100644 --- a/webapp/src/components/workspaceComponent.tsx +++ b/webapp/src/components/workspaceComponent.tsx @@ -17,7 +17,7 @@ type Props = { boardTree?: BoardTree showBoard: (id: string) => void showView: (id: string, boardId?: string) => void - setSearchText: (text: string) => void + setSearchText: (text?: string) => void setLanguage: (lang: string) => void } diff --git a/webapp/src/widgets/valueSelector.tsx b/webapp/src/widgets/valueSelector.tsx index 9eb0758f0..b015fd7a7 100644 --- a/webapp/src/widgets/valueSelector.tsx +++ b/webapp/src/widgets/valueSelector.tsx @@ -18,12 +18,12 @@ import './valueSelector.scss' type Props = { options: IPropertyOption[] - value: IPropertyOption; - emptyValue: string; - onCreate?: (value: string) => void - onChange?: (value: string) => void - onChangeColor?: (option: IPropertyOption, color: string) => void - onDeleteOption?: (option: IPropertyOption) => void + value?: IPropertyOption + emptyValue: string + onCreate: (value: string) => void + onChange: (value: string) => void + onChangeColor: (option: IPropertyOption, color: string) => void + onDeleteOption: (option: IPropertyOption) => void intl: IntlShape } From 951aba1a4d8d9dfeb016ac33adbcb88bd5adfde7 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 14:06:19 -0800 Subject: [PATCH 39/61] Focus on card title when opening cards --- webapp/src/components/cardDetail.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/webapp/src/components/cardDetail.tsx b/webapp/src/components/cardDetail.tsx index 48b3d68d2..347b7c933 100644 --- a/webapp/src/components/cardDetail.tsx +++ b/webapp/src/components/cardDetail.tsx @@ -39,10 +39,14 @@ type State = { class CardDetail extends React.Component { private titleRef = React.createRef() - shouldComponentUpdate() { + shouldComponentUpdate(): boolean { return true } + componentDidMount(): void { + this.titleRef.current?.focus() + } + constructor(props: Props) { super(props) this.state = { From f0afce41976c7e183b1cfb7eeeed1acec792e0f3 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 14:17:13 -0800 Subject: [PATCH 40/61] Fix linting --- webapp/src/cardFilter.ts | 26 +++++++++++------------ webapp/src/components/boardCard.tsx | 8 +++---- webapp/src/viewModel/boardTree.ts | 6 +++--- webapp/src/widgets/menu/menuItem.tsx | 2 +- webapp/src/widgets/menu/subMenuOption.tsx | 4 +++- webapp/src/widgets/propertyMenu.tsx | 4 ++-- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/webapp/src/cardFilter.ts b/webapp/src/cardFilter.ts index 2584bdb87..90a9cd0f1 100644 --- a/webapp/src/cardFilter.ts +++ b/webapp/src/cardFilter.ts @@ -90,17 +90,17 @@ class CardFilter { result[property.id] = property.value } return result - } else { - // And: Need to meet all clauses - const result: Record = {} - filters.forEach((filterClause) => { - const property = this.propertyThatMeetsFilterClause(filterClause as FilterClause, templates) - if (property.value) { - result[property.id] = property.value - } - }) - return result } + + // And: Need to meet all clauses + const result: Record = {} + filters.forEach((filterClause) => { + const property = this.propertyThatMeetsFilterClause(filterClause as FilterClause, templates) + if (property.value) { + result[property.id] = property.value + } + }) + return result } static propertyThatMeetsFilterClause(filterClause: FilterClause, templates: readonly IPropertyTemplate[]): { id: string, value?: string } { @@ -125,10 +125,10 @@ class CardFilter { const option = template.options.find((o) => !filterClause.values.includes(o.id)) if (option) { return {id: filterClause.propertyId, value: option.id} - } else { - // No other options exist - return {id: filterClause.propertyId} } + + // No other options exist + return {id: filterClause.propertyId} } // TODO: Handle non-select types diff --git a/webapp/src/components/boardCard.tsx b/webapp/src/components/boardCard.tsx index 7e3bd63e0..42f103f8a 100644 --- a/webapp/src/components/boardCard.tsx +++ b/webapp/src/components/boardCard.tsx @@ -3,8 +3,6 @@ import React from 'react' import {injectIntl, IntlShape} from 'react-intl' -import {MutableBlock} from '../blocks/block' - import {IPropertyTemplate} from '../blocks/board' import {Card} from '../blocks/card' import mutator from '../mutator' @@ -69,17 +67,17 @@ class BoardCard extends React.Component { this.props.onDragEnd(e) }} - onDragOver={(e) => { + onDragOver={() => { if (!this.state.isDragOver) { this.setState({isDragOver: true}) } }} - onDragEnter={(e) => { + onDragEnter={() => { if (!this.state.isDragOver) { this.setState({isDragOver: true}) } }} - onDragLeave={(e) => { + onDragLeave={() => { this.setState({isDragOver: false}) }} onDrop={(e) => { diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index e8de4741b..6843662f8 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -232,9 +232,9 @@ class MutableBoardTree implements BoardTree { } } else { // Empty group - const emptyGroupCards = this.cards.filter((o) => { - const optionId = o.properties[groupByProperty.id] - return !optionId || !groupByProperty.options.find((option) => option.id === optionId) + const emptyGroupCards = this.cards.filter((card) => { + const groupByOptionId = card.properties[groupByProperty.id] + return !groupByOptionId || !groupByProperty.options.find((option) => option.id === groupByOptionId) }) const group: Group = { option: {id: '', value: `No ${groupByProperty.name}`, color: ''}, diff --git a/webapp/src/widgets/menu/menuItem.tsx b/webapp/src/widgets/menu/menuItem.tsx index 30b76df1f..fc0384f8d 100644 --- a/webapp/src/widgets/menu/menuItem.tsx +++ b/webapp/src/widgets/menu/menuItem.tsx @@ -6,5 +6,5 @@ import React from 'react' export type MenuOptionProps = { id: string, name: string, - onClick?: (id: string) => void, + onClick: (id: string) => void, } diff --git a/webapp/src/widgets/menu/subMenuOption.tsx b/webapp/src/widgets/menu/subMenuOption.tsx index 84aff128a..72647ab60 100644 --- a/webapp/src/widgets/menu/subMenuOption.tsx +++ b/webapp/src/widgets/menu/subMenuOption.tsx @@ -8,7 +8,9 @@ import {MenuOptionProps} from './menuItem' import './subMenuOption.scss' -type SubMenuOptionProps = MenuOptionProps & { +type SubMenuOptionProps = { + id: string, + name: string, position?: 'bottom' | 'top' icon?: React.ReactNode } diff --git a/webapp/src/widgets/propertyMenu.tsx b/webapp/src/widgets/propertyMenu.tsx index d15395ee8..e6362d84d 100644 --- a/webapp/src/widgets/propertyMenu.tsx +++ b/webapp/src/widgets/propertyMenu.tsx @@ -31,8 +31,8 @@ export default class PropertyMenu extends React.PureComponent { } public componentDidMount(): void { - this.nameTextbox.current.focus() - document.execCommand('selectAll', false, null) + this.nameTextbox.current?.focus() + document.execCommand('selectAll', false, undefined) } private typeDisplayName(type: PropertyType): string { From 58ac99d9587d3cd631fa1056ff0fa54ade76c965 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 14:55:55 -0800 Subject: [PATCH 41/61] Fix linting --- webapp/src/components/boardComponent.tsx | 11 +- webapp/src/components/tableComponent.tsx | 169 ++++++++++--------- webapp/src/components/viewHeader.tsx | 69 ++++---- webapp/src/components/viewMenu.tsx | 23 +-- webapp/src/components/workspaceComponent.tsx | 30 ++-- webapp/src/mutator.ts | 8 +- webapp/src/octoClient.ts | 25 +-- webapp/src/octoListener.ts | 4 +- webapp/src/octoUtils.tsx | 4 +- webapp/src/pages/boardPage.tsx | 4 +- webapp/src/viewModel/boardTree.ts | 22 +-- webapp/src/viewModel/cardTree.ts | 2 +- webapp/src/viewModel/workspaceTree.ts | 10 +- webapp/src/widgets/menu/menuItem.tsx | 2 - webapp/src/widgets/menu/subMenuOption.tsx | 2 - 15 files changed, 189 insertions(+), 196 deletions(-) diff --git a/webapp/src/components/boardComponent.tsx b/webapp/src/components/boardComponent.tsx index 52925a4c4..2b846cf08 100644 --- a/webapp/src/components/boardComponent.tsx +++ b/webapp/src/components/boardComponent.tsx @@ -108,7 +108,7 @@ class BoardComponent extends React.Component { if (!groupByProperty) { Utils.assertFailure('Board views must have groupByProperty set') - return
+ return
} const propertyValues = groupByProperty.options || [] @@ -609,13 +609,15 @@ class BoardComponent extends React.Component { if (draggedCards.length > 0) { await mutator.performAsUndoGroup(async () => { const description = draggedCards.length > 1 ? `drag ${draggedCards.length} cards` : 'drag card' + const awaits = [] for (const draggedCard of draggedCards) { Utils.log(`ondrop. Card: ${draggedCard.title}, column: ${optionId}`) const oldValue = draggedCard.properties[boardTree.groupByProperty!.id] if (optionId !== oldValue) { - await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description) + awaits.push(mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description)) } } + await Promise.all(awaits) }) } else if (draggedHeaderOption) { Utils.log(`ondrop. Header option: ${draggedHeaderOption.value}, column: ${option?.value}`) @@ -661,14 +663,15 @@ class BoardComponent extends React.Component { await mutator.performAsUndoGroup(async () => { // Update properties of dragged cards + const awaits = [] for (const draggedCard of draggedCards) { Utils.log(`draggedCard: ${draggedCard.title}, column: ${optionId}`) const oldOptionId = draggedCard.properties[boardTree.groupByProperty!.id] if (optionId !== oldOptionId) { - await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description) + awaits.push(mutator.changePropertyValue(draggedCard, boardTree.groupByProperty!.id, optionId, description)) } } - + await Promise.all(awaits) await mutator.changeViewCardOrder(activeView, cardOrder, description) }) } diff --git a/webapp/src/components/tableComponent.tsx b/webapp/src/components/tableComponent.tsx index 1f7396261..ce6041029 100644 --- a/webapp/src/components/tableComponent.tsx +++ b/webapp/src/components/tableComponent.tsx @@ -8,8 +8,11 @@ import {BlockIcons} from '../blockIcons' import {IPropertyTemplate} from '../blocks/board' import {MutableCard} from '../blocks/card' import {BoardTree} from '../viewModel/boardTree' +import {MutableCardTree} from '../viewModel/cardTree' import mutator from '../mutator' import {Utils} from '../utils' +import {MutableBoardView} from '../blocks/boardView' +import {IBlock} from '../blocks/block' import MenuWrapper from '../widgets/menuWrapper' import SortDownIcon from '../widgets/icons/sortDown' @@ -25,10 +28,6 @@ import TableHeaderMenu from './tableHeaderMenu' import './tableComponent.scss' import {HorizontalGrip} from './horizontalGrip' -import {MutableBoardView} from '../blocks/boardView' -import {IBlock} from '../blocks/block' -import {MutableCardTree} from '../viewModel/cardTree' - type Props = { boardTree: BoardTree showView: (id: string) => void @@ -162,78 +161,83 @@ class TableComponent extends React.Component { sortIcon = sortOption.reversed ? : } - return (
{ - e.preventDefault(); (e.target as HTMLElement).classList.add('dragover') - }} - onDragEnter={(e) => { - e.preventDefault(); (e.target as HTMLElement).classList.add('dragover') - }} - onDragLeave={(e) => { - e.preventDefault(); (e.target as HTMLElement).classList.remove('dragover') - }} - onDrop={(e) => { - e.preventDefault(); (e.target as HTMLElement).classList.remove('dragover'); this.onDropToColumn(template) - }} - > - -
{ - this.draggedHeaderTemplate = template + onDragOver={(e) => { + e.preventDefault(); + (e.target as HTMLElement).classList.add('dragover') + }} + onDragEnter={(e) => { + e.preventDefault(); + (e.target as HTMLElement).classList.add('dragover') + }} + onDragLeave={(e) => { + e.preventDefault(); + (e.target as HTMLElement).classList.remove('dragover') + }} + onDrop={(e) => { + e.preventDefault(); + (e.target as HTMLElement).classList.remove('dragover') + this.onDropToColumn(template) + }} + > + +
{ + this.draggedHeaderTemplate = template + }} + onDragEnd={() => { + this.draggedHeaderTemplate = undefined + }} + > + {template.name} + {sortIcon} +
+ +
+ +
+ + { + const originalWidth = this.columnWidth(template.id) + const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset) + headerRef.current!.style.width = `${newWidth}px` }} - onDragEnd={() => { - this.draggedHeaderTemplate = undefined + 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` + + 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') + } }} - > - {template.name} - {sortIcon} -
- - - -
- - { - const originalWidth = this.columnWidth(template.id) - const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset) - 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` - - 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') - } - }} - /> -
) +
) })}
{/* Rows, one per card */} {cards.map((card) => { - const openButonRef = React.createRef() const tableRowRef = React.createRef() let focusOnMount = false @@ -242,23 +246,22 @@ class TableComponent extends React.Component { focusOnMount = true } - const tableRow = ( { - console.log('WORKING') - if (cards.length > 0 && cards[cards.length - 1] === card) { - this.addCard(false) - } - console.log('STILL WORKING') - }} - showCard={(cardId) => { - this.setState({shownCardId: cardId}) - }} - />) + const tableRow = ( + { + if (cards.length > 0 && cards[cards.length - 1] === card) { + this.addCard(false) + } + }} + showCard={(cardId) => { + this.setState({shownCardId: cardId}) + }} + />) this.cardIdToRowMap.set(card.id, tableRowRef) diff --git a/webapp/src/components/viewHeader.tsx b/webapp/src/components/viewHeader.tsx index ab1e3afc4..042f47b71 100644 --- a/webapp/src/components/viewHeader.tsx +++ b/webapp/src/components/viewHeader.tsx @@ -6,6 +6,7 @@ import {injectIntl, IntlShape, FormattedMessage} from 'react-intl' import {Archiver} from '../archiver' import {ISortOption, MutableBoardView} from '../blocks/boardView' import {BlockIcons} from '../blockIcons' +import {Constants} from '../constants' import {MutableCard} from '../blocks/card' import {IPropertyTemplate} from '../blocks/board' import {BoardTree} from '../viewModel/boardTree' @@ -13,7 +14,6 @@ import ViewMenu from '../components/viewMenu' import {CsvExporter} from '../csvExporter' import {CardFilter} from '../cardFilter' import mutator from '../mutator' -import {Utils} from '../utils' import Menu from '../widgets/menu' import MenuWrapper from '../widgets/menuWrapper' import CheckIcon from '../widgets/icons/check' @@ -24,15 +24,13 @@ import SortDownIcon from '../widgets/icons/sortDown' import ButtonWithMenu from '../widgets/buttons/buttonWithMenu' import IconButton from '../widgets/buttons/iconButton' import Button from '../widgets/buttons/button' +import DeleteIcon from '../widgets/icons/delete' import {Editable} from './editable' import FilterComponent from './filterComponent' import './viewHeader.scss' -import {Constants} from '../constants' -import DeleteIcon from '../widgets/icons/delete' - type Props = { boardTree: BoardTree showView: (id: string) => void @@ -96,7 +94,7 @@ class ViewHeader extends React.Component { const startCount = boardTree.cards.length let optionIndex = 0 - await mutator.performAsUndoGroup(async () => { + mutator.performAsUndoGroup(async () => { for (let i = 0; i < count; i++) { const card = new MutableCard() card.parentId = boardTree.board.id @@ -110,14 +108,14 @@ class ViewHeader extends React.Component { optionIndex = (optionIndex + 1) % boardTree.groupByProperty.options.length card.properties[boardTree.groupByProperty.id] = option.id } - await mutator.insertBlock(card, 'test add card') + mutator.insertBlock(card, 'test add card') } }) } private async testDistributeCards() { const {boardTree} = this.props - await mutator.performAsUndoGroup(async () => { + mutator.performAsUndoGroup(async () => { let optionIndex = 0 for (const card of boardTree.cards) { if (boardTree.groupByProperty && boardTree.groupByProperty.options.length > 0) { @@ -127,7 +125,7 @@ class ViewHeader extends React.Component { const newCard = new MutableCard(card) if (newCard.properties[boardTree.groupByProperty.id] !== option.id) { newCard.properties[boardTree.groupByProperty.id] = option.id - await mutator.updateBlock(newCard, card, 'test distribute cards') + mutator.updateBlock(newCard, card, 'test distribute cards') } } } @@ -137,9 +135,9 @@ class ViewHeader extends React.Component { private async testRandomizeIcons() { const {boardTree} = this.props - await mutator.performAsUndoGroup(async () => { + mutator.performAsUndoGroup(async () => { for (const card of boardTree.cards) { - await mutator.changeIcon(card, BlockIcons.shared.randomIcon(), 'randomize icon') + mutator.changeIcon(card, BlockIcons.shared.randomIcon(), 'randomize icon') } }) } @@ -284,28 +282,37 @@ class ViewHeader extends React.Component { } - {this.sortDisplayOptions().map((option) => ( - : : undefined} - onClick={(propertyId: string) => { - let newSortOptions: ISortOption[] = [] - if (activeView.sortOptions[0] && activeView.sortOptions[0].propertyId === propertyId) { + {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 ? : + } + } + return ( + { + 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) - }} - /> - ))} + newSortOptions = [ + {propertyId, reversed: !activeView.sortOptions[0].reversed}, + ] + } else { + newSortOptions = [ + {propertyId, reversed: false}, + ] + } + mutator.changeViewSortOptions(activeView, newSortOptions) + }} + /> + ) + })} {this.state.isSearching && diff --git a/webapp/src/components/viewMenu.tsx b/webapp/src/components/viewMenu.tsx index a3679d1e8..cbe92bb2f 100644 --- a/webapp/src/components/viewMenu.tsx +++ b/webapp/src/components/viewMenu.tsx @@ -19,8 +19,8 @@ type Props = { intl: IntlShape } -export class ViewMenu extends React.Component { - handleDeleteView = async () => { +export class ViewMenu extends React.PureComponent { + private handleDeleteView = async () => { const {boardTree, showView} = this.props Utils.log('deleteView') const view = boardTree.activeView @@ -31,7 +31,7 @@ export class ViewMenu extends React.Component { } } - handleViewClick = (id: string) => { + private handleViewClick = (id: string) => { const {boardTree, showView} = this.props Utils.log('view ' + id) const view = boardTree.views.find((o) => o.id === id) @@ -41,7 +41,7 @@ export class ViewMenu extends React.Component { } } - handleAddViewBoard = async () => { + private handleAddViewBoard = async () => { const {board, boardTree, showView, intl} = this.props Utils.log('addview-board') const view = new MutableBoardView() @@ -62,7 +62,7 @@ export class ViewMenu extends React.Component { }) } - handleAddViewTable = async () => { + private handleAddViewTable = async () => { const {board, boardTree, showView, intl} = this.props Utils.log('addview-table') @@ -87,7 +87,7 @@ export class ViewMenu extends React.Component { }) } - render() { + render(): JSX.Element { const {boardTree} = this.props return ( @@ -99,11 +99,12 @@ export class ViewMenu extends React.Component { onClick={this.handleViewClick} />))} - {boardTree.views.length > 1 && } + {boardTree.views.length > 1 && + } void } -class WorkspaceComponent extends React.Component { - render() { +class WorkspaceComponent extends React.PureComponent { + render(): JSX.Element { const {boardTree, workspaceTree, showBoard, showView, setLanguage} = this.props Utils.assert(workspaceTree) - const element = - (
+ const element = ( +
{ switch (activeView.viewType) { case 'board': { - return () + return ( + ) } case 'table': { - return () + return ( + ) } default: { diff --git a/webapp/src/mutator.ts b/webapp/src/mutator.ts index db8b73f32..42d33da62 100644 --- a/webapp/src/mutator.ts +++ b/webapp/src/mutator.ts @@ -23,7 +23,7 @@ class Mutator { private beginUndoGroup(): string | undefined { if (this.undoGroupId) { Utils.assertFailure('UndoManager does not support nested groups') - return + return undefined } this.undoGroupId = Utils.createGuid() return this.undoGroupId @@ -108,9 +108,7 @@ class Mutator { } async deleteBlock(block: IBlock, description?: string, beforeRedo?: () => Promise, afterUndo?: () => Promise) { - if (!description) { - description = `delete ${block.type}` - } + const actualDescription = description || `delete ${block.type}` await undoManager.perform( async () => { @@ -121,7 +119,7 @@ class Mutator { await octoClient.insertBlock(block) await afterUndo?.() }, - description, + actualDescription, this.undoGroupId, ) } diff --git a/webapp/src/octoClient.ts b/webapp/src/octoClient.ts index d4f6cde3c..0d7c0a628 100644 --- a/webapp/src/octoClient.ts +++ b/webapp/src/octoClient.ts @@ -36,7 +36,7 @@ class OctoClient { Utils.log(`\t ${block.type}, ${block.id}`) }) const body = JSON.stringify(blocks) - return await fetch(this.serverUrl + '/api/v1/blocks/import', { + return fetch(this.serverUrl + '/api/v1/blocks/import', { method: 'POST', headers: { Accept: 'application/json', @@ -68,35 +68,22 @@ class OctoClient { return blocks } + // TODO: Remove this fixup code fixBlocks(blocks: IMutableBlock[]): void { if (!blocks) { return } - // TODO for (const block of blocks) { if (!block.fields) { block.fields = {} } - const o = block as any - if (o.cardProperties) { - block.fields.cardProperties = o.cardProperties; delete o.cardProperties - } - if (o.properties) { - block.fields.properties = o.properties; delete o.properties - } - if (o.icon) { - block.fields.icon = o.icon; delete o.icon - } - if (o.url) { - block.fields.url = o.url; delete o.url - } } } async updateBlock(block: IMutableBlock): Promise { block.updateAt = Date.now() - return await this.insertBlocks([block]) + return this.insertBlocks([block]) } async updateBlocks(blocks: IMutableBlock[]): Promise { @@ -104,12 +91,12 @@ class OctoClient { blocks.forEach((block) => { block.updateAt = now }) - return await this.insertBlocks(blocks) + return this.insertBlocks(blocks) } async deleteBlock(blockId: string): Promise { Utils.log(`deleteBlock: ${blockId}`) - return await fetch(this.serverUrl + `/api/v1/blocks/${encodeURIComponent(blockId)}`, { + return fetch(this.serverUrl + `/api/v1/blocks/${encodeURIComponent(blockId)}`, { method: 'DELETE', headers: { Accept: 'application/json', @@ -128,7 +115,7 @@ class OctoClient { Utils.log(`\t ${block.type}, ${block.id}, ${block.title?.substr(0, 50) || ''}`) }) const body = JSON.stringify(blocks) - return await fetch(this.serverUrl + '/api/v1/blocks', { + return fetch(this.serverUrl + '/api/v1/blocks', { method: 'POST', headers: { Accept: 'application/json', diff --git a/webapp/src/octoListener.ts b/webapp/src/octoListener.ts index c19d4d172..32ade418b 100644 --- a/webapp/src/octoListener.ts +++ b/webapp/src/octoListener.ts @@ -43,7 +43,7 @@ class OctoListener { Utils.log(`OctoListener serverUrl: ${this.serverUrl}`) } - open(blockIds: string[], onChange: OnChangeHandler, onReconnect: () => void) { + open(blockIds: string[], onChange: OnChangeHandler, onReconnect: () => void): void { let timeoutId: NodeJS.Timeout if (this.ws) { @@ -107,7 +107,7 @@ class OctoListener { } } - close() { + close(): void { if (!this.ws) { return } diff --git a/webapp/src/octoUtils.tsx b/webapp/src/octoUtils.tsx index cf02ff9fd..8d9ff6a62 100644 --- a/webapp/src/octoUtils.tsx +++ b/webapp/src/octoUtils.tsx @@ -1,8 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {IBlock, IMutableBlock, MutableBlock} from './blocks/block' +import {IBlock, MutableBlock} from './blocks/block' import {IPropertyTemplate, MutableBoard} from './blocks/board' -import {BoardView, MutableBoardView} from './blocks/boardView' +import {MutableBoardView} from './blocks/boardView' import {MutableCard} from './blocks/card' import {MutableCommentBlock} from './blocks/commentBlock' import {MutableImageBlock} from './blocks/imageBlock' diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index d886a5fb1..cb557b355 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -60,7 +60,7 @@ export default class BoardPage extends React.Component { } } - undoRedoHandler = async (e: KeyboardEvent) => { + private undoRedoHandler = async (e: KeyboardEvent) => { if (e.target !== document.body) { return } @@ -181,7 +181,7 @@ export default class BoardPage extends React.Component { } private incrementalUpdate(blocks: IBlock[]) { - const {workspaceTree, boardTree, viewId} = this.state + const {workspaceTree, boardTree} = this.state let newState = {workspaceTree, boardTree} diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index 6843662f8..18ea0cd88 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -55,7 +55,7 @@ class MutableBoardTree implements BoardTree { constructor(private boardId: string) { } - async sync() { + async sync(): Promise { this.rawBlocks = await octoClient.getSubtree(this.boardId) this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } @@ -73,11 +73,11 @@ class MutableBoardTree implements BoardTree { private rebuild(blocks: IMutableBlock[]) { this.board = blocks.find((block) => block.type === 'board') as MutableBoard - this.views = blocks.filter((block) => block.type === 'view') - .sort((a, b) => a.title.localeCompare(b.title)) as MutableBoardView[] + this.views = blocks.filter((block) => block.type === 'view'). + sort((a, b) => a.title.localeCompare(b.title)) as MutableBoardView[] this.allCards = blocks.filter((block) => block.type === 'card' && !(block as Card).isTemplate) as MutableCard[] - this.cardTemplates = blocks.filter((block) => block.type === 'card' && (block as Card).isTemplate) - .sort((a, b) => a.title.localeCompare(b.title)) as MutableCard[] + this.cardTemplates = blocks.filter((block) => block.type === 'card' && (block as Card).isTemplate). + sort((a, b) => a.title.localeCompare(b.title)) as MutableCard[] this.cards = [] this.ensureMinimumSchema() @@ -119,7 +119,7 @@ class MutableBoardTree implements BoardTree { return didChange } - setActiveView(viewId: string) { + setActiveView(viewId: string): void { let view = this.views.find((o) => o.id === viewId) if (!view) { Utils.logError(`Cannot find BoardView: ${viewId}`) @@ -140,12 +140,12 @@ class MutableBoardTree implements BoardTree { return this.searchText } - setSearchText(text?: string) { + setSearchText(text?: string): void { this.searchText = text this.applyFilterSortAndGroup() } - applyFilterSortAndGroup() { + private applyFilterSortAndGroup(): void { Utils.assert(this.allCards !== undefined) this.cards = this.filterCards(this.allCards) as MutableCard[] @@ -170,11 +170,7 @@ class MutableBoardTree implements BoardTree { return cards.slice() } - return cards.filter((card) => { - if (card.title?.toLocaleLowerCase().indexOf(searchText) !== -1) { - return true - } - }) + return cards.filter((card) => card.title?.toLocaleLowerCase().indexOf(searchText) !== -1) } private setGroupByProperty(propertyId: string) { diff --git a/webapp/src/viewModel/cardTree.ts b/webapp/src/viewModel/cardTree.ts index 43eaf77dc..7d837a78a 100644 --- a/webapp/src/viewModel/cardTree.ts +++ b/webapp/src/viewModel/cardTree.ts @@ -25,7 +25,7 @@ class MutableCardTree implements CardTree { constructor(private cardId: string) { } - async sync() { + async sync(): Promise { this.rawBlocks = await octoClient.getSubtree(this.cardId) this.rebuild(OctoUtils.hydrateBlocks(this.rawBlocks)) } diff --git a/webapp/src/viewModel/workspaceTree.ts b/webapp/src/viewModel/workspaceTree.ts index 778efc1d9..38465c6a7 100644 --- a/webapp/src/viewModel/workspaceTree.ts +++ b/webapp/src/viewModel/workspaceTree.ts @@ -19,7 +19,7 @@ class MutableWorkspaceTree { private rawBlocks: IBlock[] = [] - async sync() { + async sync(): Promise { const rawBoards = await octoClient.getBlocksWithType('board') const rawViews = await octoClient.getBlocksWithType('view') this.rawBlocks = [...rawBoards, ...rawViews] @@ -37,10 +37,10 @@ class MutableWorkspaceTree { } private rebuild(blocks: IBlock[]) { - this.boards = blocks.filter((block) => block.type === 'board') - .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[] + this.boards = blocks.filter((block) => block.type === 'board'). + 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[] } mutableCopy(): MutableWorkspaceTree { diff --git a/webapp/src/widgets/menu/menuItem.tsx b/webapp/src/widgets/menu/menuItem.tsx index fc0384f8d..25fd40542 100644 --- a/webapp/src/widgets/menu/menuItem.tsx +++ b/webapp/src/widgets/menu/menuItem.tsx @@ -1,8 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react' - export type MenuOptionProps = { id: string, name: string, diff --git a/webapp/src/widgets/menu/subMenuOption.tsx b/webapp/src/widgets/menu/subMenuOption.tsx index 72647ab60..2fdb1c65d 100644 --- a/webapp/src/widgets/menu/subMenuOption.tsx +++ b/webapp/src/widgets/menu/subMenuOption.tsx @@ -4,8 +4,6 @@ import React from 'react' import SubmenuTriangleIcon from '../icons/submenuTriangle' -import {MenuOptionProps} from './menuItem' - import './subMenuOption.scss' type SubMenuOptionProps = { From 70ecdc145fb4c69f91ccfaed61ea8654a2b7c9b1 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 14:57:05 -0800 Subject: [PATCH 42/61] Fix undomanager index bug when redo-ing group --- webapp/src/undomanager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/undomanager.ts b/webapp/src/undomanager.ts index 9706b0fe7..4d32b4894 100644 --- a/webapp/src/undomanager.ts +++ b/webapp/src/undomanager.ts @@ -155,7 +155,7 @@ class UndoManager { await this.execute(command, 'redo') this.index += 1 command = this.commands[this.index + 1] - } while (this.index < this.commands.length && currentGroupId && currentGroupId === command.groupId) + } while (this.index < this.commands.length - 1 && currentGroupId && currentGroupId === command.groupId) if (this.onStateDidChange) { this.onStateDidChange() From c942c43ea7e1d0e92f332da8e81da71273f5238f Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 14:57:21 -0800 Subject: [PATCH 43/61] Update no-unused-expressions --- webapp/.eslintrc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index 5f5c27b18..0bc27cdde 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -26,7 +26,7 @@ }, "rules": { "no-unused-expressions": 0, - "babel/no-unused-expressions": 2, + "babel/no-unused-expressions": [2, {"allowShortCircuit": true}], "eol-last": ["error", "always"], "import/no-unresolved": 2, "import/order": [ @@ -110,6 +110,7 @@ "SwitchCase": 0 } ], + "no-use-before-define": "off", "@typescript-eslint/no-use-before-define": [ 2, { From d4d0f5c42d49f235a32e6e3d6be73cd8b0815942 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 15:48:31 -0800 Subject: [PATCH 44/61] Fix linting --- webapp/src/mutator.ts | 4 +++- webapp/src/undomanager.ts | 26 ++++++++++++++++++-------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/webapp/src/mutator.ts b/webapp/src/mutator.ts index 42d33da62..1b8b3ac9e 100644 --- a/webapp/src/mutator.ts +++ b/webapp/src/mutator.ts @@ -98,9 +98,11 @@ class Mutator { }, async () => { await beforeUndo?.() + const awaits = [] for (const block of blocks) { - await octoClient.deleteBlock(block.id) + awaits.push(octoClient.deleteBlock(block.id)) } + await Promise.all(awaits) }, description, this.undoGroupId, diff --git a/webapp/src/undomanager.ts b/webapp/src/undomanager.ts index 4d32b4894..2c1f58d10 100644 --- a/webapp/src/undomanager.ts +++ b/webapp/src/undomanager.ts @@ -128,11 +128,16 @@ class UndoManager { } const currentGroupId = command.groupId - do { + if (currentGroupId) { + do { + // eslint-disable-next-line no-await-in-loop + await this.execute(command, 'undo') + this.index -= 1 + command = this.commands[this.index] + } while (this.index >= 0 && currentGroupId === command.groupId) + } else { await this.execute(command, 'undo') - this.index -= 1 - command = this.commands[this.index] - } while (this.index >= 0 && currentGroupId && currentGroupId === command.groupId) + } if (this.onStateDidChange) { this.onStateDidChange() @@ -151,11 +156,16 @@ class UndoManager { } const currentGroupId = command.groupId - do { + if (currentGroupId) { + do { + // eslint-disable-next-line no-await-in-loop + await this.execute(command, 'redo') + this.index += 1 + command = this.commands[this.index + 1] + } while (this.index < this.commands.length - 1 && currentGroupId === command.groupId) + } else { await this.execute(command, 'redo') - this.index += 1 - command = this.commands[this.index + 1] - } while (this.index < this.commands.length - 1 && currentGroupId && currentGroupId === command.groupId) + } if (this.onStateDidChange) { this.onStateDidChange() From 1cc5b6d4245bc8af3e17b90cea78944ab0febc34 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 15:52:54 -0800 Subject: [PATCH 45/61] Scrollable sidebar --- webapp/src/components/sidebar.scss | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/webapp/src/components/sidebar.scss b/webapp/src/components/sidebar.scss index cf6a0b2fa..61d8fe8eb 100644 --- a/webapp/src/components/sidebar.scss +++ b/webapp/src/components/sidebar.scss @@ -7,7 +7,8 @@ min-height: 100%; color: rgb(var(--sidebar-fg)); background-color: rgb(var(--sidebar-bg)); - padding: 10px 0; + padding: 10px 0; + overflow-y: scroll; &.hidden { position: absolute; @@ -25,6 +26,10 @@ } } + >* { + flex-shrink: 0; + } + .octo-sidebar-header { display: flex; flex-direction: row; From 22e47b4eff54078823f1fd1e8f4989d9e0e0abcf Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 16:25:14 -0800 Subject: [PATCH 46/61] Fix linting --- webapp/.eslintrc.json | 2 + webapp/src/mutator.ts | 31 +++--- webapp/src/pages/boardPage.tsx | 7 +- webapp/src/viewModel/boardTree.ts | 161 ++++++++++++++---------------- 4 files changed, 94 insertions(+), 107 deletions(-) diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index 0bc27cdde..bc60529d8 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -119,6 +119,8 @@ "variables": false } ], + "no-useless-constructor": 0, + "@typescript-eslint/no-useless-constructor": 2, "react/jsx-filename-extension": 0 } }, diff --git a/webapp/src/mutator.ts b/webapp/src/mutator.ts index 1b8b3ac9e..50d351ef6 100644 --- a/webapp/src/mutator.ts +++ b/webapp/src/mutator.ts @@ -171,23 +171,18 @@ class Mutator { return } - if (index < 0) { - index = board.cardProperties.length - } - - if (!template) { - template = { - id: Utils.createGuid(), - name: 'New Property', - type: 'text', - options: [], - } + const newTemplate = template || { + id: Utils.createGuid(), + name: 'New Property', + type: 'text', + options: [], } const oldBlocks: IBlock[] = [board] const newBoard = new MutableBoard(board) - newBoard.cardProperties.splice(index, 0, template) + const startIndex = (index >= 0) ? index : board.cardProperties.length + newBoard.cardProperties.splice(startIndex, 0, newTemplate) const changedBlocks: IBlock[] = [newBoard] let description = 'add property' @@ -196,7 +191,7 @@ class Mutator { oldBlocks.push(activeView) const newActiveView = new MutableBoardView(activeView) - newActiveView.visiblePropertyIds.push(template.id) + newActiveView.visiblePropertyIds.push(newTemplate.id) changedBlocks.push(newActiveView) description = 'add column' @@ -339,7 +334,7 @@ class Mutator { } async changePropertyOptionValue(boardTree: BoardTree, propertyTemplate: IPropertyTemplate, option: IPropertyOption, value: string) { - const {board, cards} = boardTree + const {board} = boardTree const oldBlocks: IBlock[] = [board] @@ -491,8 +486,8 @@ class Mutator { async duplicateCard(cardId: string, description = 'duplicate card', afterRedo?: (newBoardId: string) => Promise, beforeUndo?: () => Promise): Promise<[IBlock[], string]> { const blocks = await octoClient.getSubtree(cardId, 2) - let [newBlocks, idMap] = OctoUtils.duplicateBlockTree(blocks, cardId) - newBlocks = newBlocks.filter((o) => o.type !== 'comment') + const [newBlocks1, idMap] = OctoUtils.duplicateBlockTree(blocks, cardId) + const newBlocks = newBlocks1.filter((o) => o.type !== 'comment') Utils.log(`duplicateCard: duplicating ${newBlocks.length} blocks`) const newCardId = idMap[cardId] const newCard = newBlocks.find((o) => o.id === newCardId)! @@ -510,8 +505,8 @@ class Mutator { async duplicateBoard(boardId: string, description = 'duplicate board', afterRedo?: (newBoardId: string) => Promise, beforeUndo?: () => Promise): Promise<[IBlock[], string]> { const blocks = await octoClient.getSubtree(boardId, 3) - let [newBlocks, idMap] = OctoUtils.duplicateBlockTree(blocks, boardId) - newBlocks = newBlocks.filter((o) => o.type !== 'comment') + const [newBlocks1, idMap] = OctoUtils.duplicateBlockTree(blocks, boardId) + const newBlocks = newBlocks1.filter((o) => o.type !== 'comment') Utils.log(`duplicateBoard: duplicating ${newBlocks.length} blocks`) const newBoardId = idMap[boardId] const newBoard = newBlocks.find((o) => o.id === newBoardId)! diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index cb557b355..61418a821 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -164,13 +164,10 @@ export default class BoardPage extends React.Component { await boardTree.sync() // Default to first view - if (!viewId) { - viewId = boardTree.views[0].id - } - - boardTree.setActiveView(viewId) + boardTree.setActiveView(viewId || boardTree.views[0].id) // TODO: Handle error (viewId not found) + this.setState({ boardTree, boardId, diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index 18ea0cd88..373d2a6ed 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -293,103 +293,96 @@ class MutableBoardTree implements BoardTree { return cards } const {sortOptions} = activeView - let sortedCards: Card[] = [] if (sortOptions.length < 1) { Utils.log('Manual sort') - sortedCards = cards.sort((a, b) => this.manualOrder(activeView, a, b)) - } else { - sortOptions.forEach((sortOption) => { - if (sortOption.propertyId === Constants.titleColumnId) { - Utils.log('Sort by title') - sortedCards = cards.sort((a, b) => { - let result = this.titleOrCreatedOrder(a, b) + return cards.sort((a, b) => this.manualOrder(activeView, a, b)) + } - if (sortOption.reversed) { - result = -result - } - return result - }) - } else { - const sortPropertyId = sortOption.propertyId - const template = board.cardProperties.find((o) => o.id === sortPropertyId) - if (!template) { - Utils.logError(`Missing template for property id: ${sortPropertyId}`) - return cards.slice() + let sortedCards = cards + for (const sortOption of sortOptions) { + if (sortOption.propertyId === Constants.titleColumnId) { + Utils.log('Sort by title') + sortedCards = sortedCards.sort((a, b) => { + const result = this.titleOrCreatedOrder(a, b) + return sortOption.reversed ? -result : result + }) + } else { + const sortPropertyId = sortOption.propertyId + const template = board.cardProperties.find((o) => o.id === sortPropertyId) + if (!template) { + Utils.logError(`Missing template for property id: ${sortPropertyId}`) + return sortedCards + } + Utils.log(`Sort by property: ${template?.name}`) + sortedCards = sortedCards.sort((a, b) => { + // Always put cards with no titles at the bottom, regardless of sort + if (!a.title || !b.title) { + return this.titleOrCreatedOrder(a, b) } - Utils.log(`Sort by property: ${template?.name}`) - sortedCards = cards.sort((a, b) => { - // Always put cards with no titles at the bottom, regardless of sort - if (!a.title || !b.title) { + + const aValue = a.properties[sortPropertyId] || '' + const bValue = b.properties[sortPropertyId] || '' + let result = 0 + if (template.type === 'select') { + // Always put empty values at the bottom + if (aValue && !bValue) { + return -1 + } + if (bValue && !aValue) { + return 1 + } + if (!aValue && !bValue) { return this.titleOrCreatedOrder(a, b) } - const aValue = a.properties[sortPropertyId] || '' - const bValue = b.properties[sortPropertyId] || '' - let result = 0 - if (template.type === 'select') { - // Always put empty values at the bottom - if (aValue && !bValue) { - return -1 - } - if (bValue && !aValue) { - return 1 - } - if (!aValue && !bValue) { - return this.titleOrCreatedOrder(a, b) - } + // Sort by the option order (not alphabetically by value) + const aOrder = template.options.findIndex((o) => o.id === aValue) + const bOrder = template.options.findIndex((o) => o.id === bValue) - // Sort by the option order (not alphabetically by value) - const aOrder = template.options.findIndex((o) => o.id === aValue) - const bOrder = template.options.findIndex((o) => o.id === bValue) - - result = aOrder - bOrder - } else if (template.type === 'number' || template.type === 'date') { - // Always put empty values at the bottom - if (aValue && !bValue) { - return -1 - } - if (bValue && !aValue) { - return 1 - } - if (!aValue && !bValue) { - return this.titleOrCreatedOrder(a, b) - } - - result = Number(aValue) - Number(bValue) - } else if (template.type === 'createdTime') { - result = a.createAt - b.createAt - } else if (template.type === 'updatedTime') { - result = a.updateAt - b.updateAt - } else { - // Text-based sort - - // Always put empty values at the bottom - if (aValue && !bValue) { - return -1 - } - if (bValue && !aValue) { - return 1 - } - if (!aValue && !bValue) { - return this.titleOrCreatedOrder(a, b) - } - - result = aValue.localeCompare(bValue) + result = aOrder - bOrder + } else if (template.type === 'number' || template.type === 'date') { + // Always put empty values at the bottom + if (aValue && !bValue) { + return -1 + } + if (bValue && !aValue) { + return 1 + } + if (!aValue && !bValue) { + return this.titleOrCreatedOrder(a, b) } - if (result === 0) { - // In case of "ties", use the title order - result = this.titleOrCreatedOrder(a, b) + result = Number(aValue) - Number(bValue) + } else if (template.type === 'createdTime') { + result = a.createAt - b.createAt + } else if (template.type === 'updatedTime') { + result = a.updateAt - b.updateAt + } else { + // Text-based sort + + // Always put empty values at the bottom + if (aValue && !bValue) { + return -1 + } + if (bValue && !aValue) { + return 1 + } + if (!aValue && !bValue) { + return this.titleOrCreatedOrder(a, b) } - if (sortOption.reversed) { - result = -result - } - return result - }) - } - }) + result = aValue.localeCompare(bValue) + } + + if (result === 0) { + // In case of "ties", use the title order + result = this.titleOrCreatedOrder(a, b) + } + + return sortOption.reversed ? -result : result + }) + } } return sortedCards From 465fe41fd51561cfe5e8f8132dc7e6daf500e049 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 17:24:24 -0800 Subject: [PATCH 47/61] Fix linting and strictNullChecks --- webapp/src/archiver.ts | 2 +- webapp/src/blocks/board.ts | 1 + webapp/src/blocks/boardView.ts | 6 +- webapp/src/blocks/card.ts | 1 + webapp/src/blocks/imageBlock.ts | 1 + webapp/src/blocks/orderedBlock.ts | 1 + webapp/src/components/blockIconSelector.tsx | 4 +- webapp/src/components/boardColumn.tsx | 19 ++- webapp/src/components/cardDialog.tsx | 15 ++- webapp/src/components/contentBlock.tsx | 2 +- webapp/src/components/editable.tsx | 57 ++++----- webapp/src/components/filterComponent.tsx | 129 ++++++++++---------- webapp/src/components/flashMessages.tsx | 14 ++- webapp/src/components/horizontalGrip.tsx | 9 +- webapp/src/components/markdownEditor.tsx | 62 +++++----- webapp/src/mutator.ts | 2 +- webapp/src/widgets/editable.tsx | 8 +- 17 files changed, 169 insertions(+), 164 deletions(-) diff --git a/webapp/src/archiver.ts b/webapp/src/archiver.ts index ad597e753..926efa542 100644 --- a/webapp/src/archiver.ts +++ b/webapp/src/archiver.ts @@ -58,7 +58,7 @@ class Archiver { input.type = 'file' input.accept = '.octo' input.onchange = async () => { - const file = input.files[0] + const file = input.files && input.files[0] const contents = await (new Response(file)).text() Utils.log(`Import ${contents.length} bytes.`) const archive: Archive = JSON.parse(contents) diff --git a/webapp/src/blocks/board.ts b/webapp/src/blocks/board.ts index 5277d008a..168dc9390 100644 --- a/webapp/src/blocks/board.ts +++ b/webapp/src/blocks/board.ts @@ -57,6 +57,7 @@ class MutableBoard extends MutableBlock { super(block) this.type = 'board' + this.icon = block.fields?.icon || '' if (block.fields?.cardProperties) { // Deep clone of card properties and their options this.cardProperties = block.fields.cardProperties.map((o: IPropertyTemplate) => { diff --git a/webapp/src/blocks/boardView.ts b/webapp/src/blocks/boardView.ts index e42847b3e..07dcdb7ac 100644 --- a/webapp/src/blocks/boardView.ts +++ b/webapp/src/blocks/boardView.ts @@ -15,7 +15,7 @@ interface BoardView extends IBlock { readonly visiblePropertyIds: readonly string[] readonly visibleOptionIds: readonly string[] readonly hiddenOptionIds: readonly string[] - readonly filter: FilterGroup | undefined + readonly filter: FilterGroup readonly cardOrder: readonly string[] readonly columnWidths: Readonly> } @@ -63,10 +63,10 @@ class MutableBoardView extends MutableBlock implements BoardView { this.fields.hiddenOptionIds = value } - get filter(): FilterGroup | undefined { + get filter(): FilterGroup { return this.fields.filter } - set filter(value: FilterGroup | undefined) { + set filter(value: FilterGroup) { this.fields.filter = value } diff --git a/webapp/src/blocks/card.ts b/webapp/src/blocks/card.ts index 495e9b091..9e0abef4a 100644 --- a/webapp/src/blocks/card.ts +++ b/webapp/src/blocks/card.ts @@ -38,6 +38,7 @@ class MutableCard extends MutableBlock { super(block) this.type = 'card' + this.icon = block.fields?.icon || '' this.properties = {...(block.fields?.properties || {})} } diff --git a/webapp/src/blocks/imageBlock.ts b/webapp/src/blocks/imageBlock.ts index da69c2bf4..1a62856a8 100644 --- a/webapp/src/blocks/imageBlock.ts +++ b/webapp/src/blocks/imageBlock.ts @@ -17,6 +17,7 @@ class MutableImageBlock extends MutableOrderedBlock implements IOrderedBlock { constructor(block: any = {}) { super(block) this.type = 'image' + this.url = block.fields?.url || '' } } diff --git a/webapp/src/blocks/orderedBlock.ts b/webapp/src/blocks/orderedBlock.ts index 346a5b60f..861991c14 100644 --- a/webapp/src/blocks/orderedBlock.ts +++ b/webapp/src/blocks/orderedBlock.ts @@ -18,6 +18,7 @@ class MutableOrderedBlock extends MutableBlock implements IOrderedBlock { constructor(block: any = {}) { super(block) + this.order = block.fields?.order || 0 } } diff --git a/webapp/src/components/blockIconSelector.tsx b/webapp/src/components/blockIconSelector.tsx index 428b41ea0..b5038d30a 100644 --- a/webapp/src/components/blockIconSelector.tsx +++ b/webapp/src/components/blockIconSelector.tsx @@ -37,7 +37,7 @@ class BlockIconSelector extends React.Component { document.body.click() } - render(): JSX.Element { + render(): JSX.Element | null { const {block, intl, size} = this.props if (!block.icon) { return null @@ -64,7 +64,7 @@ class BlockIconSelector extends React.Component { id='remove' icon={} name={intl.formatMessage({id: 'ViewTitle.remove-icon', defaultMessage: 'Remove Icon'})} - onClick={() => mutator.changeIcon(block, undefined, 'remove icon')} + onClick={() => mutator.changeIcon(block, '', 'remove icon')} />
diff --git a/webapp/src/components/boardColumn.tsx b/webapp/src/components/boardColumn.tsx index 84204c423..601d4883a 100644 --- a/webapp/src/components/boardColumn.tsx +++ b/webapp/src/components/boardColumn.tsx @@ -3,27 +3,26 @@ import React from 'react' type Props = { - onDrop?: (e: React.DragEvent) => void - isDropZone?: boolean + onDrop: (e: React.DragEvent) => void + isDropZone: boolean } type State = { - isDragOver?: boolean + isDragOver: boolean } -class BoardColumn extends React.Component { - constructor(props: Props) { - super(props) - this.state = {} +class BoardColumn extends React.PureComponent { + state = { + isDragOver: false, } - render() { + render(): JSX.Element { let className = 'octo-board-column' if (this.props.isDropZone && this.state.isDragOver) { className += ' dragover' } - const element = - (
{ e.preventDefault() diff --git a/webapp/src/components/cardDialog.tsx b/webapp/src/components/cardDialog.tsx index 27e95bd0b..775591bbc 100644 --- a/webapp/src/components/cardDialog.tsx +++ b/webapp/src/components/cardDialog.tsx @@ -9,8 +9,7 @@ import mutator from '../mutator' import Menu from '../widgets/menu' import DeleteIcon from '../widgets/icons/delete' -import {MutableCardTree} from '../viewModel/cardTree' -import {CardTree} from '../viewModel/cardTree' +import {MutableCardTree, CardTree} from '../viewModel/cardTree' import {OctoListener} from '../octoListener' import {Utils} from '../utils' @@ -33,11 +32,11 @@ class CardDialog extends React.Component { private cardListener?: OctoListener - shouldComponentUpdate() { + shouldComponentUpdate(): boolean { return true } - componentDidMount() { + componentDidMount(): void { this.createCardTreeAndSync() } @@ -55,26 +54,26 @@ class CardDialog extends React.Component { [this.props.cardId], async (blocks) => { Utils.log(`cardListener.onChanged: ${blocks.length}`) - const newCardTree = this.state.cardTree.mutableCopy() + const newCardTree = this.state.cardTree!.mutableCopy() if (newCardTree.incrementalUpdate(blocks)) { this.setState({cardTree: newCardTree}) } }, async () => { Utils.log('cardListener.onReconnect') - const newCardTree = this.state.cardTree.mutableCopy() + const newCardTree = this.state.cardTree!.mutableCopy() await newCardTree.sync() this.setState({cardTree: newCardTree}) }, ) } - componentWillUnmount() { + componentWillUnmount(): void { this.cardListener?.close() this.cardListener = undefined } - render() { + render(): JSX.Element { const {cardTree} = this.state const menu = ( diff --git a/webapp/src/components/contentBlock.tsx b/webapp/src/components/contentBlock.tsx index ae9ad7897..1ee5ee943 100644 --- a/webapp/src/components/contentBlock.tsx +++ b/webapp/src/components/contentBlock.tsx @@ -33,7 +33,7 @@ type Props = { } class ContentBlock extends React.PureComponent { - public render(): JSX.Element { + public render(): JSX.Element | null { const {cardId, contents, block} = this.props if (block.type !== 'text' && block.type !== 'image' && block.type !== 'divider') { diff --git a/webapp/src/components/editable.tsx b/webapp/src/components/editable.tsx index cc01c27a1..e04b0154c 100644 --- a/webapp/src/components/editable.tsx +++ b/webapp/src/components/editable.tsx @@ -19,10 +19,7 @@ type Props = { onKeyDown?: (e: React.KeyboardEvent) => void } -type State = { -} - -class Editable extends React.Component { +class Editable extends React.PureComponent { static defaultProps = { text: '', isMarkdown: false, @@ -30,46 +27,46 @@ class Editable extends React.Component { allowEmpty: true, } - private _text = '' + private privateText = '' get text(): string { - return this._text + return this.privateText } set text(value: string) { const {isMarkdown} = this.props - if (!value) { - this.elementRef.current.innerText = '' + if (value) { + this.elementRef.current!.innerHTML = isMarkdown ? Utils.htmlFromMarkdown(value) : Utils.htmlEncode(value) } else { - this.elementRef.current.innerHTML = isMarkdown ? Utils.htmlFromMarkdown(value) : Utils.htmlEncode(value) + this.elementRef.current!.innerText = '' } - this._text = value || '' + this.privateText = value || '' } private elementRef = React.createRef() constructor(props: Props) { super(props) - this._text = props.text || '' + this.privateText = props.text || '' } - componentDidUpdate() { - this._text = this.props.text || '' + componentDidUpdate(): void { + this.privateText = this.props.text || '' } - focus() { - this.elementRef.current.focus() + focus(): void { + this.elementRef.current!.focus() // Put cursor at end - document.execCommand('selectAll', false, null) - document.getSelection().collapseToEnd() + document.execCommand('selectAll', false, undefined) + document.getSelection()?.collapseToEnd() } - blur() { - this.elementRef.current.blur() + blur(): void { + this.elementRef.current!.blur() } - render() { + render(): JSX.Element { const {text, className, style, placeholderText, isMarkdown, isMultiline, onFocus, onBlur, onKeyDown, onChanged} = this.props const initialStyle = {...this.props.style} @@ -81,8 +78,8 @@ class Editable extends React.Component { html = '' } - const element = - (
{ dangerouslySetInnerHTML={{__html: html}} onFocus={() => { - this.elementRef.current.innerText = this.text - this.elementRef.current.style.color = style?.color || null - this.elementRef.current.classList.add('active') + this.elementRef.current!.innerText = this.text + this.elementRef.current!.style!.color = style?.color || '' + this.elementRef.current!.classList.add('active') if (onFocus) { onFocus() @@ -103,7 +100,7 @@ class Editable extends React.Component { }} onBlur={async () => { - const newText = this.elementRef.current.innerText + const newText = this.elementRef.current!.innerText const oldText = this.props.text || '' if (this.props.allowEmpty || newText) { if (newText !== oldText && onChanged) { @@ -115,7 +112,7 @@ class Editable extends React.Component { this.text = oldText // Reset text } - this.elementRef.current.classList.remove('active') + this.elementRef.current!.classList.remove('active') if (onBlur) { onBlur() } @@ -124,17 +121,17 @@ class Editable extends React.Component { 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) { onKeyDown(e) } }} - />); + />) return element } diff --git a/webapp/src/components/filterComponent.tsx b/webapp/src/components/filterComponent.tsx index d660baa84..cb0dc7204 100644 --- a/webapp/src/components/filterComponent.tsx +++ b/webapp/src/components/filterComponent.tsx @@ -85,69 +85,70 @@ class FilterComponent extends React.Component { const propertyName = template ? template.name : '(unknown)' // TODO: Handle error const key = `${filter.propertyId}-${filter.condition}-${filter.values.join(',')}` Utils.log(`FilterClause key: ${key}`) - return (
- - - - {board.cardProperties.filter((o) => o.type === 'select').map((o) => ( + return ( +
+ + + + {board.cardProperties.filter((o) => o.type === 'select').map((o) => ( + { + const filterIndex = activeView.filter.filters.indexOf(filter) + Utils.assert(filterIndex >= 0, "Can't find filter") + const filterGroup = new FilterGroup(activeView.filter) + const newFilter = filterGroup.filters[filterIndex] as FilterClause + Utils.assert(newFilter, `No filter at index ${filterIndex}`) + if (newFilter.propertyId !== optionId) { + newFilter.propertyId = optionId + newFilter.values = [] + mutator.changeViewFilter(activeView, filterGroup) + } + }} + />))} + + + + + { - const filterIndex = activeView.filter.filters.indexOf(filter) - Utils.assert(filterIndex >= 0, "Can't find filter") - const filterGroup = new FilterGroup(activeView.filter) - const newFilter = filterGroup.filters[filterIndex] as FilterClause - Utils.assert(newFilter, `No filter at index ${filterIndex}`) - if (newFilter.propertyId !== optionId) { - newFilter.propertyId = optionId - newFilter.values = [] - mutator.changeViewFilter(activeView, filterGroup) - } - }} - />))} - - - - - - this.conditionClicked(id, filter)} + id='includes' + name={intl.formatMessage({id: 'Filter.includes', defaultMessage: 'includes'})} + onClick={(id) => this.conditionClicked(id, filter)} + /> + this.conditionClicked(id, filter)} + /> + this.conditionClicked(id, filter)} + /> + this.conditionClicked(id, filter)} + /> + + + { + template && this.filterValue(filter, template) + } +
+
-
- { - this.filterValue(filter, template) - } -
- -
) + +
) })}
@@ -162,7 +163,7 @@ class FilterComponent extends React.Component { ) } - private filterValue(filter: FilterClause, template: IPropertyTemplate): JSX.Element { + private filterValue(filter: FilterClause, template: IPropertyTemplate): JSX.Element | undefined { const {boardTree} = this.props const {activeView: view} = boardTree @@ -177,10 +178,6 @@ class FilterComponent extends React.Component { displayValue = '(empty)' } - if (!template) { - return null - } - return ( diff --git a/webapp/src/components/flashMessages.tsx b/webapp/src/components/flashMessages.tsx index 2936248e0..a82efd605 100644 --- a/webapp/src/components/flashMessages.tsx +++ b/webapp/src/components/flashMessages.tsx @@ -26,7 +26,7 @@ type State = { } export class FlashMessages extends React.PureComponent { - private timeout: ReturnType = null + private timeout?: ReturnType constructor(props: Props) { super(props) @@ -35,7 +35,7 @@ export class FlashMessages extends React.PureComponent { emitter.on('message', (message: FlashMessage) => { if (this.timeout) { clearTimeout(this.timeout) - this.timeout = null + this.timeout = undefined } this.timeout = setTimeout(this.handleFadeOut, this.props.milliseconds - 200) this.setState({message}) @@ -48,16 +48,18 @@ export class FlashMessages extends React.PureComponent { } handleTimeout = (): void => { - this.setState({message: null, fadeOut: false}) + this.setState({message: undefined, fadeOut: false}) } handleClick = (): void => { - clearTimeout(this.timeout) - this.timeout = null + if (this.timeout) { + clearTimeout(this.timeout) + this.timeout = undefined + } this.handleFadeOut() } - public render(): JSX.Element { + public render(): JSX.Element | null { if (!this.state.message) { return null } diff --git a/webapp/src/components/horizontalGrip.tsx b/webapp/src/components/horizontalGrip.tsx index 2473676c4..4490b23fc 100644 --- a/webapp/src/components/horizontalGrip.tsx +++ b/webapp/src/components/horizontalGrip.tsx @@ -10,13 +10,16 @@ type Props = { } type State = { - isDragging?: boolean - startX?: number - offset?: number + isDragging: boolean + startX: number + offset: number } class HorizontalGrip extends React.PureComponent { state: State = { + isDragging: false, + startX: 0, + offset: 0, } render(): JSX.Element { diff --git a/webapp/src/components/markdownEditor.tsx b/webapp/src/components/markdownEditor.tsx index 86fad88f5..eb2e9ae9a 100644 --- a/webapp/src/components/markdownEditor.tsx +++ b/webapp/src/components/markdownEditor.tsx @@ -29,13 +29,13 @@ class MarkdownEditor extends React.Component { } get text(): string { - return this.elementRef.current.state.value + return this.elementRef.current!.state.value } set text(value: string) { - this.elementRef.current.setState({value}) + this.elementRef.current!.setState({value}) } - private editorInstance: EasyMDE + private editorInstance?: EasyMDE private frameRef = React.createRef() private elementRef = React.createRef() private previewRef = React.createRef() @@ -45,14 +45,18 @@ class MarkdownEditor extends React.Component { this.state = {isEditing: false} } - componentDidUpdate(prevProps: Props, prevState: State) { + shouldComponentUpdate(): boolean { + return true + } + + componentDidUpdate(): void { const newText = this.props.text || '' if (!this.state.isEditing && this.text !== newText) { this.text = newText } } - showEditor() { + showEditor(): void { const cm = this.editorInstance?.codemirror if (cm) { setTimeout(() => { @@ -66,12 +70,12 @@ class MarkdownEditor extends React.Component { this.setState({isEditing: true}) } - hideEditor() { + hideEditor(): void { this.editorInstance?.codemirror?.getInputField()?.blur() this.setState({isEditing: false}) } - render() { + render(): JSX.Element { const {text, placeholderText, uniqueId, onFocus, onBlur, onChange} = this.props let html: string @@ -81,21 +85,21 @@ class MarkdownEditor extends React.Component { html = Utils.htmlFromMarkdown(placeholderText || '') } - const previewElement = - (
{ - if (!this.state.isEditing) { - this.showEditor() - } - }} - />); + const previewElement = ( +
{ + if (!this.state.isEditing) { + this.showEditor() + } + }} + />) - const editorElement = - (
{ events={{ change: () => { if (this.state.isEditing) { - const newText = this.elementRef.current.state.value + const newText = this.elementRef.current!.state.value onChange?.(newText) } }, blur: () => { - const newText = this.elementRef.current.state.value + const newText = this.elementRef.current!.state.value const oldText = this.props.text || '' if (newText !== oldText && onChange) { const newHtml = newText ? Utils.htmlFromMarkdown(newText) : Utils.htmlFromMarkdown(placeholderText || '') - this.previewRef.current.innerHTML = newHtml + this.previewRef.current!.innerHTML = newHtml onChange(newText) } this.text = newText - this.frameRef.current.classList.remove('active') + this.frameRef.current!.classList.remove('active') if (onBlur) { onBlur(newText) @@ -149,9 +153,9 @@ class MarkdownEditor extends React.Component { this.hideEditor() }, focus: () => { - this.frameRef.current.classList.add('active') + this.frameRef.current!.classList.add('active') - this.elementRef.current.setState({value: this.text}) + this.elementRef.current!.setState({value: this.text}) if (onFocus) { onFocus() @@ -176,8 +180,8 @@ class MarkdownEditor extends React.Component { />
) - const element = - (
diff --git a/webapp/src/mutator.ts b/webapp/src/mutator.ts index 50d351ef6..8950d90b2 100644 --- a/webapp/src/mutator.ts +++ b/webapp/src/mutator.ts @@ -421,7 +421,7 @@ class Mutator { await this.updateBlock(newView, view, 'sort') } - async changeViewFilter(view: BoardView, filter?: FilterGroup): Promise { + async changeViewFilter(view: BoardView, filter: FilterGroup): Promise { const newView = new MutableBoardView(view) newView.filter = filter await this.updateBlock(newView, view, 'filter') diff --git a/webapp/src/widgets/editable.tsx b/webapp/src/widgets/editable.tsx index a665776d0..3be215e42 100644 --- a/webapp/src/widgets/editable.tsx +++ b/webapp/src/widgets/editable.tsx @@ -24,16 +24,16 @@ export default class Editable extends React.Component { } public focus(): void { - this.elementRef.current.focus() + this.elementRef.current!.focus() // Put cursor at end - document.execCommand('selectAll', false, null) - document.getSelection().collapseToEnd() + document.execCommand('selectAll', false, undefined) + document.getSelection()?.collapseToEnd() } public blur = (): void => { this.saveOnBlur = false - this.elementRef.current.blur() + this.elementRef.current!.blur() this.saveOnBlur = true } From 40679b57fc9e8ab05f00a2b498243ef5ee998b66 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 17:26:32 -0800 Subject: [PATCH 48/61] Fix linting --- webapp/src/widgets/menuWrapper.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/webapp/src/widgets/menuWrapper.tsx b/webapp/src/widgets/menuWrapper.tsx index 2bd8a8014..638aed948 100644 --- a/webapp/src/widgets/menuWrapper.tsx +++ b/webapp/src/widgets/menuWrapper.tsx @@ -31,13 +31,13 @@ export default class MenuWrapper extends React.PureComponent { this.node = React.createRef() } - public componentDidMount() { + public componentDidMount(): void { document.addEventListener('menuItemClicked', this.close, true) document.addEventListener('click', this.closeOnBlur, true) document.addEventListener('keyup', this.keyboardClose, true) } - public componentWillUnmount() { + public componentWillUnmount(): void { document.removeEventListener('menuItemClicked', this.close, true) document.removeEventListener('click', this.closeOnBlur, true) document.removeEventListener('keyup', this.keyboardClose, true) @@ -61,13 +61,13 @@ export default class MenuWrapper extends React.PureComponent { this.close() } - public close = () => { + public close = (): void => { if (this.state.open) { this.setState({open: false}) } } - toggle = (e: React.MouseEvent) => { + private toggle = (e: React.MouseEvent): void => { /** * 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 @@ -82,7 +82,7 @@ export default class MenuWrapper extends React.PureComponent { this.setState({open: newState}) } - public render() { + public render(): JSX.Element { const {children} = this.props return ( From ef7bdad528f97f3c1543824cdaf09234fbfe3cc6 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 17:28:32 -0800 Subject: [PATCH 49/61] Show sidebar menu on left --- webapp/src/components/sidebar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webapp/src/components/sidebar.tsx b/webapp/src/components/sidebar.tsx index 8bc2ea3d6..63c377741 100644 --- a/webapp/src/components/sidebar.tsx +++ b/webapp/src/components/sidebar.tsx @@ -16,6 +16,7 @@ import HamburgerIcon from '../widgets/icons/hamburger' import DeleteIcon from '../widgets/icons/delete' import SubmenuTriangleIcon from '../widgets/icons/submenuTriangle' import DotIcon from '../widgets/icons/dot' +import DuplicateIcon from '../widgets/icons/duplicate' import IconButton from '../widgets/buttons/iconButton' import Button from '../widgets/buttons/button' import {WorkspaceTree} from '../viewModel/workspaceTree' @@ -117,7 +118,7 @@ class Sidebar extends React.Component {
}/> - + { } onClick={async () => { await mutator.duplicateBoard( board.id, From adf1ca81bd9888da81039ba6fe7e83d3c428040f Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 17:31:54 -0800 Subject: [PATCH 50/61] Turn on strictNullChecks in tsconfig --- webapp/tsconfig.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json index 2897478d8..bbcb59d1e 100644 --- a/webapp/tsconfig.json +++ b/webapp/tsconfig.json @@ -6,7 +6,7 @@ "esModuleInterop": true, "noImplicitAny": true, "strict": true, - "strictNullChecks": false, + "strictNullChecks": true, "forceConsistentCasingInFileNames": true, "sourceMap": true, "allowJs": true, @@ -26,5 +26,9 @@ "." ], "exclude": [ - ] + ".git", + "**/node_modules/*", + "dist", + "pack" + ] } From 286dfd67bbef652b49656b8b9a1db93ccd35edfe Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 17:34:52 -0800 Subject: [PATCH 51/61] Fix linting --- webapp/src/components/commentsList.tsx | 2 +- webapp/src/pages/loginPage.tsx | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/webapp/src/components/commentsList.tsx b/webapp/src/components/commentsList.tsx index bf477b6db..241e25a75 100644 --- a/webapp/src/components/commentsList.tsx +++ b/webapp/src/components/commentsList.tsx @@ -79,7 +79,7 @@ class CommentsList extends React.Component { text={this.state.newComment} placeholderText={intl.formatMessage({id: 'CardDetail.new-comment-placeholder', defaultMessage: 'Add a comment...'})} onChange={(value: string) => { - if (this.state.newComment != value) { + if (this.state.newComment !== value) { this.setState({newComment: value}) } }} diff --git a/webapp/src/pages/loginPage.tsx b/webapp/src/pages/loginPage.tsx index 113c0c300..1122c9f43 100644 --- a/webapp/src/pages/loginPage.tsx +++ b/webapp/src/pages/loginPage.tsx @@ -8,20 +8,21 @@ import Button from '../widgets/buttons/button' import './loginPage.scss' -type Props = {} - -type State = { - username: string; - password: string; +type Props = { } -export default class LoginPage extends React.Component { +type State = { + username: string + password: string +} + +export default class LoginPage extends React.PureComponent { state = { username: '', password: '', } - handleLogin = () => { + private handleLogin = (): void => { Utils.log('Logging in') } @@ -29,7 +30,7 @@ export default class LoginPage extends React.Component { return (
- + { />
- + { onChange={(e) => this.setState({password: e.target.value})} />
- +
) } From 8f7e6c0ba59081d5dc4e50f9ff5873a1a7b89e5f Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 18:48:59 -0800 Subject: [PATCH 52/61] Organize imports --- webapp/src/components/blockIconSelector.tsx | 7 ++-- webapp/src/components/boardCard.tsx | 11 +++---- webapp/src/components/boardComponent.tsx | 25 +++++++-------- webapp/src/components/cardDialog.tsx | 12 +++---- webapp/src/components/comment.tsx | 14 ++++---- webapp/src/components/commentsList.tsx | 8 ++--- webapp/src/components/contentBlock.tsx | 32 +++++++++---------- webapp/src/components/markdownEditor.tsx | 3 +- .../src/components/propertyValueElement.tsx | 5 ++- webapp/src/components/sidebar.tsx | 27 ++++++++-------- webapp/src/components/tableComponent.tsx | 20 ++++++------ webapp/src/components/tableHeaderMenu.tsx | 1 - webapp/src/components/tableRow.tsx | 8 ++--- webapp/src/components/viewHeader.tsx | 31 +++++++++--------- webapp/src/components/viewMenu.tsx | 5 ++- webapp/src/components/viewTitle.tsx | 5 ++- webapp/src/components/workspaceComponent.tsx | 3 +- webapp/src/octoUtils.tsx | 2 +- webapp/src/pages/boardPage.tsx | 6 ++-- webapp/src/pages/loginPage.tsx | 2 -- webapp/src/viewModel/boardTree.ts | 2 +- webapp/src/viewModel/cardTree.ts | 2 +- webapp/src/viewModel/workspaceTree.ts | 6 ++-- 23 files changed, 106 insertions(+), 131 deletions(-) diff --git a/webapp/src/components/blockIconSelector.tsx b/webapp/src/components/blockIconSelector.tsx index b5038d30a..4f8b2673c 100644 --- a/webapp/src/components/blockIconSelector.tsx +++ b/webapp/src/components/blockIconSelector.tsx @@ -7,12 +7,11 @@ import {BlockIcons} from '../blockIcons' import {Board} from '../blocks/board' import {Card} from '../blocks/card' import mutator from '../mutator' +import EmojiPicker from '../widgets/emojiPicker' +import DeleteIcon from '../widgets/icons/delete' +import EmojiIcon from '../widgets/icons/emoji' import Menu from '../widgets/menu' import MenuWrapper from '../widgets/menuWrapper' -import EmojiPicker from '../widgets/emojiPicker' -import EmojiIcon from '../widgets/icons/emoji' -import DeleteIcon from '../widgets/icons/delete' - import './blockIconSelector.scss' type Props = { diff --git a/webapp/src/components/boardCard.tsx b/webapp/src/components/boardCard.tsx index 42f103f8a..98dcd3b02 100644 --- a/webapp/src/components/boardCard.tsx +++ b/webapp/src/components/boardCard.tsx @@ -6,16 +6,15 @@ import {injectIntl, IntlShape} from 'react-intl' import {IPropertyTemplate} from '../blocks/board' import {Card} from '../blocks/card' import mutator from '../mutator' -import MenuWrapper from '../widgets/menuWrapper' -import Menu from '../widgets/menu' -import OptionsIcon from '../widgets/icons/options' +import IconButton from '../widgets/buttons/iconButton' import DeleteIcon from '../widgets/icons/delete' import DuplicateIcon from '../widgets/icons/duplicate' -import IconButton from '../widgets/buttons/iconButton' - -import PropertyValueElement from './propertyValueElement' +import OptionsIcon from '../widgets/icons/options' +import Menu from '../widgets/menu' +import MenuWrapper from '../widgets/menuWrapper' import './boardCard.scss' +import PropertyValueElement from './propertyValueElement' type BoardCardProps = { card: Card diff --git a/webapp/src/components/boardComponent.tsx b/webapp/src/components/boardComponent.tsx index 2b846cf08..79d19d3f8 100644 --- a/webapp/src/components/boardComponent.tsx +++ b/webapp/src/components/boardComponent.tsx @@ -2,38 +2,37 @@ // See LICENSE.txt for license information. /* eslint-disable max-lines */ import React from 'react' -import {injectIntl, IntlShape, FormattedMessage} from 'react-intl' +import {FormattedMessage, injectIntl, IntlShape} from 'react-intl' import {BlockIcons} from '../blockIcons' -import {IPropertyOption, IPropertyTemplate} from '../blocks/board' import {IBlock} from '../blocks/block' +import {IPropertyOption, IPropertyTemplate} from '../blocks/board' import {Card, MutableCard} from '../blocks/card' -import {BoardTree, BoardTreeGroup} from '../viewModel/boardTree' -import {MutableCardTree} from '../viewModel/cardTree' import {CardFilter} from '../cardFilter' import {Constants} from '../constants' import mutator from '../mutator' import {Utils} from '../utils' -import Menu from '../widgets/menu' -import MenuWrapper from '../widgets/menuWrapper' -import OptionsIcon from '../widgets/icons/options' -import AddIcon from '../widgets/icons/add' -import HideIcon from '../widgets/icons/hide' -import ShowIcon from '../widgets/icons/show' -import DeleteIcon from '../widgets/icons/delete' +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' +import DeleteIcon from '../widgets/icons/delete' +import HideIcon from '../widgets/icons/hide' +import OptionsIcon from '../widgets/icons/options' +import ShowIcon from '../widgets/icons/show' +import Menu from '../widgets/menu' +import MenuWrapper from '../widgets/menuWrapper' import BoardCard from './boardCard' import {BoardColumn} from './boardColumn' +import './boardComponent.scss' import {CardDialog} from './cardDialog' import {Editable} from './editable' import RootPortal from './rootPortal' import ViewHeader from './viewHeader' import ViewTitle from './viewTitle' -import './boardComponent.scss' - type Props = { boardTree: BoardTree showView: (id: string) => void diff --git a/webapp/src/components/cardDialog.tsx b/webapp/src/components/cardDialog.tsx index 775591bbc..5e19542d4 100644 --- a/webapp/src/components/cardDialog.tsx +++ b/webapp/src/components/cardDialog.tsx @@ -1,20 +1,18 @@ // 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 {BoardTree} from '../viewModel/boardTree' import mutator from '../mutator' -import Menu from '../widgets/menu' -import DeleteIcon from '../widgets/icons/delete' - -import {MutableCardTree, CardTree} from '../viewModel/cardTree' import {OctoListener} from '../octoListener' import {Utils} from '../utils' +import {BoardTree} from '../viewModel/boardTree' +import {CardTree, MutableCardTree} from '../viewModel/cardTree' +import DeleteIcon from '../widgets/icons/delete' +import Menu from '../widgets/menu' -import Dialog from './dialog' import CardDetail from './cardDetail' +import Dialog from './dialog' type Props = { boardTree: BoardTree diff --git a/webapp/src/components/comment.tsx b/webapp/src/components/comment.tsx index 99a1c661e..07a15b70d 100644 --- a/webapp/src/components/comment.tsx +++ b/webapp/src/components/comment.tsx @@ -1,19 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, {FC} from 'react' -import {IntlShape, injectIntl} from 'react-intl' +import {injectIntl, IntlShape} from 'react-intl' -import mutator from '../mutator' import {IBlock} from '../blocks/block' - -import Menu from '../widgets/menu' -import MenuWrapper from '../widgets/menuWrapper' +import mutator from '../mutator' +import {Utils} from '../utils' +import IconButton from '../widgets/buttons/iconButton' import DeleteIcon from '../widgets/icons/delete' import OptionsIcon from '../widgets/icons/options' -import IconButton from '../widgets/buttons/iconButton' - +import Menu from '../widgets/menu' +import MenuWrapper from '../widgets/menuWrapper' import './comment.scss' -import {Utils} from '../utils' type Props = { comment: IBlock diff --git a/webapp/src/components/commentsList.tsx b/webapp/src/components/commentsList.tsx index 241e25a75..dff78c467 100644 --- a/webapp/src/components/commentsList.tsx +++ b/webapp/src/components/commentsList.tsx @@ -1,17 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React from 'react' -import {injectIntl, IntlShape, FormattedMessage} from 'react-intl' +import {FormattedMessage, injectIntl, IntlShape} from 'react-intl' -import {MutableCommentBlock} from '../blocks/commentBlock' import {IBlock} from '../blocks/block' -import {Utils} from '../utils' +import {MutableCommentBlock} from '../blocks/commentBlock' import mutator from '../mutator' - +import {Utils} from '../utils' import Button from '../widgets/buttons/button' import Comment from './comment' - import './commentsList.scss' import {MarkdownEditor} from './markdownEditor' diff --git a/webapp/src/components/contentBlock.tsx b/webapp/src/components/contentBlock.tsx index 1ee5ee943..bf54113c6 100644 --- a/webapp/src/components/contentBlock.tsx +++ b/webapp/src/components/contentBlock.tsx @@ -3,28 +3,26 @@ import React from 'react' -import {IOrderedBlock} from '../blocks/orderedBlock' -import {OctoUtils} from '../octoUtils' -import mutator from '../mutator' -import {Utils} from '../utils' -import {MutableTextBlock} from '../blocks/textBlock' import {MutableDividerBlock} from '../blocks/dividerBlock' - +import {IOrderedBlock} from '../blocks/orderedBlock' +import {MutableTextBlock} from '../blocks/textBlock' +import mutator from '../mutator' +import {OctoUtils} from '../octoUtils' +import {Utils} from '../utils' +import IconButton from '../widgets/buttons/iconButton' +import AddIcon from '../widgets/icons/add' +import DeleteIcon from '../widgets/icons/delete' +import DividerIcon from '../widgets/icons/divider' +import ImageIcon from '../widgets/icons/image' +import OptionsIcon from '../widgets/icons/options' +import SortDownIcon from '../widgets/icons/sortDown' +import SortUpIcon from '../widgets/icons/sortUp' +import TextIcon from '../widgets/icons/text' import Menu from '../widgets/menu' import MenuWrapper from '../widgets/menuWrapper' -import OptionsIcon from '../widgets/icons/options' -import SortUpIcon from '../widgets/icons/sortUp' -import SortDownIcon from '../widgets/icons/sortDown' -import DeleteIcon from '../widgets/icons/delete' -import AddIcon from '../widgets/icons/add' -import TextIcon from '../widgets/icons/text' -import ImageIcon from '../widgets/icons/image' -import DividerIcon from '../widgets/icons/divider' -import IconButton from '../widgets/buttons/iconButton' - -import {MarkdownEditor} from './markdownEditor' import './contentBlock.scss' +import {MarkdownEditor} from './markdownEditor' type Props = { block: IOrderedBlock diff --git a/webapp/src/components/markdownEditor.tsx b/webapp/src/components/markdownEditor.tsx index eb2e9ae9a..523a24377 100644 --- a/webapp/src/components/markdownEditor.tsx +++ b/webapp/src/components/markdownEditor.tsx @@ -4,9 +4,8 @@ import EasyMDE from 'easymde' import React from 'react' import SimpleMDE from 'react-simplemde-editor' -import './markdownEditor.scss' - import {Utils} from '../utils' +import './markdownEditor.scss' type Props = { text?: string diff --git a/webapp/src/components/propertyValueElement.tsx b/webapp/src/components/propertyValueElement.tsx index c55d215ab..227acee26 100644 --- a/webapp/src/components/propertyValueElement.tsx +++ b/webapp/src/components/propertyValueElement.tsx @@ -3,13 +3,12 @@ import React from 'react' +import {IPropertyOption, IPropertyTemplate} from '../blocks/board' import {Card} from '../blocks/card' -import {IPropertyTemplate, IPropertyOption} from '../blocks/board' -import {OctoUtils} from '../octoUtils' import mutator from '../mutator' +import {OctoUtils} from '../octoUtils' import {Utils} from '../utils' import {BoardTree} from '../viewModel/boardTree' - import Editable from '../widgets/editable' import ValueSelector from '../widgets/valueSelector' diff --git a/webapp/src/components/sidebar.tsx b/webapp/src/components/sidebar.tsx index 63c377741..4326a24b1 100644 --- a/webapp/src/components/sidebar.tsx +++ b/webapp/src/components/sidebar.tsx @@ -1,27 +1,26 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React from 'react' -import {injectIntl, IntlShape, FormattedMessage} from 'react-intl' +import {FormattedMessage, injectIntl, IntlShape} from 'react-intl' import {Archiver} from '../archiver' -import {mattermostTheme, darkTheme, lightTheme, setTheme} from '../theme' import {Board, MutableBoard} from '../blocks/board' +import {BoardView, MutableBoardView} from '../blocks/boardView' import mutator from '../mutator' -import Menu from '../widgets/menu' -import MenuWrapper from '../widgets/menuWrapper' -import OptionsIcon from '../widgets/icons/options' -import ShowSidebarIcon from '../widgets/icons/showSidebar' -import HideSidebarIcon from '../widgets/icons/hideSidebar' -import HamburgerIcon from '../widgets/icons/hamburger' +import {darkTheme, lightTheme, mattermostTheme, setTheme} from '../theme' +import {WorkspaceTree} from '../viewModel/workspaceTree' +import Button from '../widgets/buttons/button' +import IconButton from '../widgets/buttons/iconButton' import DeleteIcon from '../widgets/icons/delete' -import SubmenuTriangleIcon from '../widgets/icons/submenuTriangle' import DotIcon from '../widgets/icons/dot' import DuplicateIcon from '../widgets/icons/duplicate' -import IconButton from '../widgets/buttons/iconButton' -import Button from '../widgets/buttons/button' -import {WorkspaceTree} from '../viewModel/workspaceTree' -import {BoardView, MutableBoardView} from '../blocks/boardView' - +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 SubmenuTriangleIcon from '../widgets/icons/submenuTriangle' +import Menu from '../widgets/menu' +import MenuWrapper from '../widgets/menuWrapper' import './sidebar.scss' type Props = { diff --git a/webapp/src/components/tableComponent.tsx b/webapp/src/components/tableComponent.tsx index ce6041029..e183ab07d 100644 --- a/webapp/src/components/tableComponent.tsx +++ b/webapp/src/components/tableComponent.tsx @@ -3,30 +3,28 @@ import React from 'react' import {FormattedMessage} from 'react-intl' -import {Constants} from '../constants' import {BlockIcons} from '../blockIcons' +import {IBlock} from '../blocks/block' import {IPropertyTemplate} from '../blocks/board' +import {MutableBoardView} from '../blocks/boardView' import {MutableCard} from '../blocks/card' -import {BoardTree} from '../viewModel/boardTree' -import {MutableCardTree} from '../viewModel/cardTree' +import {Constants} from '../constants' import mutator from '../mutator' import {Utils} from '../utils' -import {MutableBoardView} from '../blocks/boardView' -import {IBlock} from '../blocks/block' - -import MenuWrapper from '../widgets/menuWrapper' +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 {HorizontalGrip} from './horizontalGrip' import RootPortal from './rootPortal' +import './tableComponent.scss' +import TableHeaderMenu from './tableHeaderMenu' import {TableRow} from './tableRow' import ViewHeader from './viewHeader' import ViewTitle from './viewTitle' -import TableHeaderMenu from './tableHeaderMenu' - -import './tableComponent.scss' -import {HorizontalGrip} from './horizontalGrip' type Props = { boardTree: BoardTree diff --git a/webapp/src/components/tableHeaderMenu.tsx b/webapp/src/components/tableHeaderMenu.tsx index 559fbd83f..e420f2955 100644 --- a/webapp/src/components/tableHeaderMenu.tsx +++ b/webapp/src/components/tableHeaderMenu.tsx @@ -5,7 +5,6 @@ import React, {FC} from 'react' import {injectIntl, IntlShape} from 'react-intl' import {Constants} from '../constants' - import mutator from '../mutator' import {BoardTree} from '../viewModel/boardTree' import Menu from '../widgets/menu' diff --git a/webapp/src/components/tableRow.tsx b/webapp/src/components/tableRow.tsx index d9267f67d..fdbb5aac6 100644 --- a/webapp/src/components/tableRow.tsx +++ b/webapp/src/components/tableRow.tsx @@ -3,16 +3,14 @@ import React from 'react' import {FormattedMessage} from 'react-intl' -import {BoardTree} from '../viewModel/boardTree' import {Card} from '../blocks/card' -import mutator from '../mutator' - import {Constants} from '../constants' -import Editable from '../widgets/editable' +import mutator from '../mutator' +import {BoardTree} from '../viewModel/boardTree' import Button from '../widgets/buttons/button' +import Editable from '../widgets/editable' import PropertyValueElement from './propertyValueElement' - import './tableRow.scss' type Props = { diff --git a/webapp/src/components/viewHeader.tsx b/webapp/src/components/viewHeader.tsx index 042f47b71..5c65c6bb7 100644 --- a/webapp/src/components/viewHeader.tsx +++ b/webapp/src/components/viewHeader.tsx @@ -1,34 +1,33 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React from 'react' -import {injectIntl, IntlShape, FormattedMessage} from 'react-intl' +import {FormattedMessage, injectIntl, IntlShape} from 'react-intl' import {Archiver} from '../archiver' -import {ISortOption, MutableBoardView} from '../blocks/boardView' import {BlockIcons} from '../blockIcons' -import {Constants} from '../constants' -import {MutableCard} from '../blocks/card' import {IPropertyTemplate} from '../blocks/board' -import {BoardTree} from '../viewModel/boardTree' -import ViewMenu from '../components/viewMenu' -import {CsvExporter} from '../csvExporter' +import {ISortOption, MutableBoardView} from '../blocks/boardView' +import {MutableCard} from '../blocks/card' import {CardFilter} from '../cardFilter' +import ViewMenu from '../components/viewMenu' +import {Constants} from '../constants' +import {CsvExporter} from '../csvExporter' import mutator from '../mutator' -import Menu from '../widgets/menu' -import MenuWrapper from '../widgets/menuWrapper' -import CheckIcon from '../widgets/icons/check' -import DropdownIcon from '../widgets/icons/dropdown' -import OptionsIcon from '../widgets/icons/options' -import SortUpIcon from '../widgets/icons/sortUp' -import SortDownIcon from '../widgets/icons/sortDown' +import {BoardTree} from '../viewModel/boardTree' +import Button from '../widgets/buttons/button' import ButtonWithMenu from '../widgets/buttons/buttonWithMenu' import IconButton from '../widgets/buttons/iconButton' -import Button from '../widgets/buttons/button' +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' +import SortUpIcon from '../widgets/icons/sortUp' +import Menu from '../widgets/menu' +import MenuWrapper from '../widgets/menuWrapper' import {Editable} from './editable' import FilterComponent from './filterComponent' - import './viewHeader.scss' type Props = { diff --git a/webapp/src/components/viewMenu.tsx b/webapp/src/components/viewMenu.tsx index cbe92bb2f..c1def03ec 100644 --- a/webapp/src/components/viewMenu.tsx +++ b/webapp/src/components/viewMenu.tsx @@ -1,16 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React from 'react' - import {injectIntl, IntlShape} from 'react-intl' import {Board} from '../blocks/board' import {MutableBoardView} from '../blocks/boardView' -import {BoardTree} from '../viewModel/boardTree' +import {Constants} from '../constants' import mutator from '../mutator' import {Utils} from '../utils' +import {BoardTree} from '../viewModel/boardTree' import Menu from '../widgets/menu' -import {Constants} from '../constants' type Props = { boardTree: BoardTree diff --git a/webapp/src/components/viewTitle.tsx b/webapp/src/components/viewTitle.tsx index bd1fe06f7..0afb77e69 100644 --- a/webapp/src/components/viewTitle.tsx +++ b/webapp/src/components/viewTitle.tsx @@ -1,17 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React from 'react' -import {injectIntl, IntlShape, FormattedMessage} from 'react-intl' +import {FormattedMessage, injectIntl, IntlShape} from 'react-intl' import {BlockIcons} from '../blockIcons' import {Board} from '../blocks/board' import mutator from '../mutator' -import Editable from '../widgets/editable' import Button from '../widgets/buttons/button' +import Editable from '../widgets/editable' import EmojiIcon from '../widgets/icons/emoji' import BlockIconSelector from './blockIconSelector' - import './viewTitle.scss' type Props = { diff --git a/webapp/src/components/workspaceComponent.tsx b/webapp/src/components/workspaceComponent.tsx index ce8abca95..e1bf0f0fa 100644 --- a/webapp/src/components/workspaceComponent.tsx +++ b/webapp/src/components/workspaceComponent.tsx @@ -2,14 +2,13 @@ // See LICENSE.txt for license information. import React from 'react' -import {BoardTree} from '../viewModel/boardTree' import {Utils} from '../utils' +import {BoardTree} from '../viewModel/boardTree' import {WorkspaceTree} from '../viewModel/workspaceTree' import BoardComponent from './boardComponent' import Sidebar from './sidebar' import {TableComponent} from './tableComponent' - import './workspaceComponent.scss' type Props = { diff --git a/webapp/src/octoUtils.tsx b/webapp/src/octoUtils.tsx index 8d9ff6a62..70f3296e1 100644 --- a/webapp/src/octoUtils.tsx +++ b/webapp/src/octoUtils.tsx @@ -5,8 +5,8 @@ import {IPropertyTemplate, MutableBoard} from './blocks/board' import {MutableBoardView} from './blocks/boardView' import {MutableCard} from './blocks/card' import {MutableCommentBlock} from './blocks/commentBlock' -import {MutableImageBlock} from './blocks/imageBlock' import {MutableDividerBlock} from './blocks/dividerBlock' +import {MutableImageBlock} from './blocks/imageBlock' import {IOrderedBlock} from './blocks/orderedBlock' import {MutableTextBlock} from './blocks/textBlock' import {Utils} from './utils' diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index 61418a821..166b5000f 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -2,14 +2,14 @@ // See LICENSE.txt for license information. import React from 'react' -import {BoardTree, MutableBoardTree} from '../viewModel/boardTree' -import {WorkspaceComponent} from '../components/workspaceComponent' +import {IBlock} from '../blocks/block' import {sendFlashMessage} from '../components/flashMessages' +import {WorkspaceComponent} from '../components/workspaceComponent' import mutator from '../mutator' import {OctoListener} from '../octoListener' import {Utils} from '../utils' +import {BoardTree, MutableBoardTree} from '../viewModel/boardTree' import {MutableWorkspaceTree, WorkspaceTree} from '../viewModel/workspaceTree' -import {IBlock} from '../blocks/block' type Props = { setLanguage: (lang: string) => void diff --git a/webapp/src/pages/loginPage.tsx b/webapp/src/pages/loginPage.tsx index 1122c9f43..97e81554f 100644 --- a/webapp/src/pages/loginPage.tsx +++ b/webapp/src/pages/loginPage.tsx @@ -3,9 +3,7 @@ import React from 'react' import {Utils} from '../utils' - import Button from '../widgets/buttons/button' - import './loginPage.scss' type Props = { diff --git a/webapp/src/viewModel/boardTree.ts b/webapp/src/viewModel/boardTree.ts index 373d2a6ed..f291e3f59 100644 --- a/webapp/src/viewModel/boardTree.ts +++ b/webapp/src/viewModel/boardTree.ts @@ -1,12 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {IBlock, IMutableBlock} from '../blocks/block' import {Board, IPropertyOption, IPropertyTemplate, MutableBoard} from '../blocks/board' import {BoardView, MutableBoardView} from '../blocks/boardView' import {Card, MutableCard} from '../blocks/card' import {CardFilter} from '../cardFilter' import {Constants} from '../constants' import octoClient from '../octoClient' -import {IBlock, IMutableBlock} from '../blocks/block' import {OctoUtils} from '../octoUtils' import {Utils} from '../utils' diff --git a/webapp/src/viewModel/cardTree.ts b/webapp/src/viewModel/cardTree.ts index 7d837a78a..1ec470da6 100644 --- a/webapp/src/viewModel/cardTree.ts +++ b/webapp/src/viewModel/cardTree.ts @@ -1,9 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {IBlock, MutableBlock} from '../blocks/block' import {Card, MutableCard} from '../blocks/card' import {IOrderedBlock} from '../blocks/orderedBlock' import octoClient from '../octoClient' -import {IBlock, MutableBlock} from '../blocks/block' import {OctoUtils} from '../octoUtils' interface CardTree { diff --git a/webapp/src/viewModel/workspaceTree.ts b/webapp/src/viewModel/workspaceTree.ts index 38465c6a7..0bd67001c 100644 --- a/webapp/src/viewModel/workspaceTree.ts +++ b/webapp/src/viewModel/workspaceTree.ts @@ -1,10 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Board} from '../blocks/board' -import octoClient from '../octoClient' import {IBlock} from '../blocks/block' -import {OctoUtils} from '../octoUtils' +import {Board} from '../blocks/board' import {BoardView} from '../blocks/boardView' +import octoClient from '../octoClient' +import {OctoUtils} from '../octoUtils' interface WorkspaceTree { readonly boards: readonly Board[] From a67236b1aff560098f75aa187f069d9c80f3b0bc Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 18:49:06 -0800 Subject: [PATCH 53/61] Fix linting --- webapp/src/components/rootPortal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/webapp/src/components/rootPortal.tsx b/webapp/src/components/rootPortal.tsx index 3aef91865..68bdf6c5a 100644 --- a/webapp/src/components/rootPortal.tsx +++ b/webapp/src/components/rootPortal.tsx @@ -1,9 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import PropTypes from 'prop-types' import React from 'react' import ReactDOM from 'react-dom' -import PropTypes from 'prop-types' type Props = { children: React.ReactNode @@ -21,21 +21,21 @@ export default class RootPortal extends React.PureComponent { this.el = document.createElement('div') } - componentDidMount() { + componentDidMount(): void { const rootPortal = document.getElementById('root-portal') if (rootPortal) { rootPortal.appendChild(this.el) } } - componentWillUnmount() { + componentWillUnmount(): void { const rootPortal = document.getElementById('root-portal') if (rootPortal) { rootPortal.removeChild(this.el) } } - render() { + render(): JSX.Element { return ReactDOM.createPortal( this.props.children, this.el, From 0c2409cadb6b680669d6ea5f2443a4dea3dca3fc Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 18:49:23 -0800 Subject: [PATCH 54/61] Fix css --- webapp/src/components/cardDetail.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/components/cardDetail.scss b/webapp/src/components/cardDetail.scss index a5a216440..642346bb3 100644 --- a/webapp/src/components/cardDetail.scss +++ b/webapp/src/components/cardDetail.scss @@ -24,7 +24,7 @@ display: flex; flex-direction: column; width: 100%; - .MenuWrapper: { + .MenuWrapper { position: relative; } } From a335ad18ffe67b275531f6bd3d5096e96f4050b4 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 16 Nov 2020 10:28:21 -0800 Subject: [PATCH 55/61] Don't wrap sidebar titles --- webapp/src/components/sidebar.scss | 3 +++ webapp/src/components/sidebar.tsx | 16 ++++------------ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/webapp/src/components/sidebar.scss b/webapp/src/components/sidebar.scss index 61d8fe8eb..4ec55d06b 100644 --- a/webapp/src/components/sidebar.scss +++ b/webapp/src/components/sidebar.scss @@ -112,6 +112,9 @@ .octo-sidebar-title { cursor: pointer; flex-grow: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .OptionsIcon, .SubmenuTriangleIcon, .DotIcon { diff --git a/webapp/src/components/sidebar.tsx b/webapp/src/components/sidebar.tsx index 4326a24b1..4b60c62d2 100644 --- a/webapp/src/components/sidebar.tsx +++ b/webapp/src/components/sidebar.tsx @@ -89,12 +89,7 @@ class Sidebar extends React.Component {
{ boards.map((board) => { - const displayTitle = board.title || ( - - ) + const displayTitle: string = board.title || intl.formatMessage({id: 'Sidebar.untitled-board', defaultMessage: '(Untitled Board)'}) const boardViews = views.filter((view) => view.parentId === board.id) return (
@@ -112,6 +107,7 @@ class Sidebar extends React.Component { onClick={() => { this.boardClicked(board) }} + title={displayTitle} > {board.icon ? `${board.icon} ${displayTitle}` : displayTitle}
@@ -175,13 +171,9 @@ class Sidebar extends React.Component { onClick={() => { this.viewClicked(board, view) }} + title={view.title || intl.formatMessage({id: 'Sidebar.untitled-view', defaultMessage: '(Untitled View)'})} > - {view.title || ( - - )} + {view.title || intl.formatMessage({id: 'Sidebar.untitled-view', defaultMessage: '(Untitled View)'})}
))} From ba052ac3051b89c3a8d8ded03aff9816a01d34d7 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 16 Nov 2020 10:37:29 -0800 Subject: [PATCH 56/61] Fix ungrouped undo-redo --- webapp/src/undomanager.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webapp/src/undomanager.ts b/webapp/src/undomanager.ts index 2c1f58d10..303987106 100644 --- a/webapp/src/undomanager.ts +++ b/webapp/src/undomanager.ts @@ -137,6 +137,7 @@ class UndoManager { } while (this.index >= 0 && currentGroupId === command.groupId) } else { await this.execute(command, 'undo') + this.index -= 1 } if (this.onStateDidChange) { @@ -165,6 +166,7 @@ class UndoManager { } while (this.index < this.commands.length - 1 && currentGroupId === command.groupId) } else { await this.execute(command, 'redo') + this.index += 1 } if (this.onStateDidChange) { From 9c70ff2ca23173c5423e1f44bfb5210e9e682d73 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 16 Nov 2020 10:43:13 -0800 Subject: [PATCH 57/61] Sidebar CSS: Don't shrink dots and disclosure icons --- webapp/src/components/sidebar.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/webapp/src/components/sidebar.scss b/webapp/src/components/sidebar.scss index 4ec55d06b..bd92386ac 100644 --- a/webapp/src/components/sidebar.scss +++ b/webapp/src/components/sidebar.scss @@ -119,6 +119,7 @@ .OptionsIcon, .SubmenuTriangleIcon, .DotIcon { fill: rgba(var(--sidebar-fg), 0.5); + flex-shrink: 0; } .HideSidebarIcon { From d2d02585b2a4391849a00df61d1a633ce16f8263 Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 16 Nov 2020 12:29:10 -0800 Subject: [PATCH 58/61] Make IconButton a pure component --- webapp/src/widgets/buttons/iconButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/widgets/buttons/iconButton.tsx b/webapp/src/widgets/buttons/iconButton.tsx index 291807eed..2c10cdef4 100644 --- a/webapp/src/widgets/buttons/iconButton.tsx +++ b/webapp/src/widgets/buttons/iconButton.tsx @@ -10,8 +10,8 @@ type Props = { icon?: React.ReactNode } -export default class Button extends React.Component { - render() { +export default class Button extends React.PureComponent { + render(): JSX.Element { return (
Date: Mon, 16 Nov 2020 12:29:25 -0800 Subject: [PATCH 59/61] Centered DisclosureTriangleIcon --- webapp/src/components/sidebar.scss | 6 +++--- webapp/src/components/sidebar.tsx | 4 ++-- .../src/widgets/icons/disclosureTriangle.scss | 6 ++++++ .../src/widgets/icons/disclosureTriangle.tsx | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 webapp/src/widgets/icons/disclosureTriangle.scss create mode 100644 webapp/src/widgets/icons/disclosureTriangle.tsx diff --git a/webapp/src/components/sidebar.scss b/webapp/src/components/sidebar.scss index bd92386ac..2e6490ad1 100644 --- a/webapp/src/components/sidebar.scss +++ b/webapp/src/components/sidebar.scss @@ -99,11 +99,11 @@ } &.expanded { - .SubmenuTriangleIcon { + .DisclosureTriangleIcon { transform: rotate(90deg); } } - .SubmenuTriangleIcon { + .DisclosureTriangleIcon { transition: 200ms ease-in-out; transform: rotate(0deg); } @@ -117,7 +117,7 @@ text-overflow: ellipsis; } - .OptionsIcon, .SubmenuTriangleIcon, .DotIcon { + .OptionsIcon, .DisclosureTriangleIcon, .DotIcon { fill: rgba(var(--sidebar-fg), 0.5); flex-shrink: 0; } diff --git a/webapp/src/components/sidebar.tsx b/webapp/src/components/sidebar.tsx index 4b60c62d2..24a82bc85 100644 --- a/webapp/src/components/sidebar.tsx +++ b/webapp/src/components/sidebar.tsx @@ -18,7 +18,7 @@ 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 SubmenuTriangleIcon from '../widgets/icons/submenuTriangle' +import DisclosureTriangle from '../widgets/icons/disclosureTriangle' import Menu from '../widgets/menu' import MenuWrapper from '../widgets/menuWrapper' import './sidebar.scss' @@ -95,7 +95,7 @@ class Sidebar extends React.Component {
} + icon={} onClick={() => { const newCollapsedBoards = {...this.state.collapsedBoards} newCollapsedBoards[board.id] = !newCollapsedBoards[board.id] diff --git a/webapp/src/widgets/icons/disclosureTriangle.scss b/webapp/src/widgets/icons/disclosureTriangle.scss new file mode 100644 index 000000000..9dffff683 --- /dev/null +++ b/webapp/src/widgets/icons/disclosureTriangle.scss @@ -0,0 +1,6 @@ +.DisclosureTriangleIcon { + fill: rgba(var(--main-fg), 0.7); + stroke: none; + width: 24px; + height: 24px; +} diff --git a/webapp/src/widgets/icons/disclosureTriangle.tsx b/webapp/src/widgets/icons/disclosureTriangle.tsx new file mode 100644 index 000000000..d569dd176 --- /dev/null +++ b/webapp/src/widgets/icons/disclosureTriangle.tsx @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react' + +import './disclosureTriangle.scss' + +export default function DisclosureTriangle(): JSX.Element { + return ( + + + + ) +} From 82a398487bbacb2901eab2cdb591f8bde8b34ccf Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 16 Nov 2020 16:48:52 -0800 Subject: [PATCH 60/61] Unit test: UndoManager --- .vscode/launch.json | 33 ++++++++++++++++++++++-- webapp/src/undoManager.test.ts | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 webapp/src/undoManager.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index ddd2a303e..b71485f81 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,6 +20,35 @@ "/**" ], "type": "pwa-node" - } - ] + }, + { + "type": "node", + "request": "launch", + "name": "Jest: run all tests", + "program": "${workspaceRoot}/webapp/node_modules/jest/bin/jest.js", + "cwd": "${workspaceRoot}/webapp", + "args": [ + "--verbose", + "-i", + "--no-cache" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, + { + "type": "node", + "request": "launch", + "name": "Jest: run current file", + "program": "${workspaceRoot}/webapp/node_modules/jest/bin/jest.js", + "cwd": "${workspaceRoot}/webapp", + "args": [ + "${fileBasename}", + "--verbose", + "-i", + "--no-cache", + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, + ] } diff --git a/webapp/src/undoManager.test.ts b/webapp/src/undoManager.test.ts new file mode 100644 index 000000000..46bffc394 --- /dev/null +++ b/webapp/src/undoManager.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import undoManager from './undomanager' +import {Utils} from './utils' + +test('Basic undo/redo', async () => { + expect(!undoManager.canUndo).toBe(true) + expect(!undoManager.canRedo).toBe(true) + + const values: string[] = [] + + await undoManager.perform( + async () => { + values.push('a') + }, + async () => { + values.pop() + }, + 'test', + ) + + expect(undoManager.canUndo).toBe(true) + expect(undoManager.canRedo).toBe(false) + expect(Utils.arraysEqual(values, ['a'])).toBe(true) + expect(undoManager.undoDescription).toBe('test') + expect(undoManager.redoDescription).toBe(undefined) + + await undoManager.undo() + expect(undoManager.canUndo).toBe(false) + expect(undoManager.canRedo).toBe(true) + expect(Utils.arraysEqual(values, [])).toBe(true) + expect(undoManager.undoDescription).toBe(undefined) + expect(undoManager.redoDescription).toBe('test') + + await undoManager.redo() + expect(undoManager.canUndo).toBe(true) + expect(undoManager.canRedo).toBe(false) + expect(Utils.arraysEqual(values, ['a'])).toBe(true) + + await undoManager.clear() + expect(undoManager.canUndo).toBe(false) + expect(undoManager.canRedo).toBe(false) + expect(undoManager.undoDescription).toBe(undefined) + expect(undoManager.redoDescription).toBe(undefined) +}) From b1b6d5b070697c2ef1078cd2fbe0cacf236791aa Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Mon, 16 Nov 2020 17:00:10 -0800 Subject: [PATCH 61/61] Unit test: grouped undo/redo --- .vscode/launch.json | 4 -- webapp/src/undoManager.test.ts | 82 +++++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index b71485f81..767f843f8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -29,8 +29,6 @@ "cwd": "${workspaceRoot}/webapp", "args": [ "--verbose", - "-i", - "--no-cache" ], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen" @@ -44,8 +42,6 @@ "args": [ "${fileBasename}", "--verbose", - "-i", - "--no-cache", ], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen" diff --git a/webapp/src/undoManager.test.ts b/webapp/src/undoManager.test.ts index 46bffc394..b01934781 100644 --- a/webapp/src/undoManager.test.ts +++ b/webapp/src/undoManager.test.ts @@ -5,8 +5,8 @@ import undoManager from './undomanager' import {Utils} from './utils' test('Basic undo/redo', async () => { - expect(!undoManager.canUndo).toBe(true) - expect(!undoManager.canRedo).toBe(true) + expect(undoManager.canUndo).toBe(false) + expect(undoManager.canRedo).toBe(false) const values: string[] = [] @@ -44,3 +44,81 @@ test('Basic undo/redo', async () => { expect(undoManager.undoDescription).toBe(undefined) expect(undoManager.redoDescription).toBe(undefined) }) + +test('Grouped undo/redo', async () => { + expect(undoManager.canUndo).toBe(false) + expect(undoManager.canRedo).toBe(false) + + const values: string[] = [] + const groupId = 'the group id' + + await undoManager.perform( + async () => { + values.push('a') + }, + async () => { + values.pop() + }, + 'insert a', + ) + + expect(undoManager.canUndo).toBe(true) + expect(undoManager.canRedo).toBe(false) + expect(Utils.arraysEqual(values, ['a'])).toBe(true) + expect(undoManager.undoDescription).toBe('insert a') + expect(undoManager.redoDescription).toBe(undefined) + + await undoManager.perform( + async () => { + values.push('b') + }, + async () => { + values.pop() + }, + 'insert b', + groupId, + ) + + expect(undoManager.canUndo).toBe(true) + expect(undoManager.canRedo).toBe(false) + expect(Utils.arraysEqual(values, ['a', 'b'])).toBe(true) + expect(undoManager.undoDescription).toBe('insert b') + expect(undoManager.redoDescription).toBe(undefined) + + await undoManager.perform( + async () => { + values.push('c') + }, + async () => { + values.pop() + }, + 'insert c', + groupId, + ) + + expect(undoManager.canUndo).toBe(true) + expect(undoManager.canRedo).toBe(false) + expect(Utils.arraysEqual(values, ['a', 'b', 'c'])).toBe(true) + expect(undoManager.undoDescription).toBe('insert c') + expect(undoManager.redoDescription).toBe(undefined) + + await undoManager.undo() + expect(undoManager.canUndo).toBe(true) + expect(undoManager.canRedo).toBe(true) + expect(Utils.arraysEqual(values, ['a'])).toBe(true) + expect(undoManager.undoDescription).toBe('insert a') + expect(undoManager.redoDescription).toBe('insert b') + + await undoManager.redo() + expect(undoManager.canUndo).toBe(true) + expect(undoManager.canRedo).toBe(false) + expect(Utils.arraysEqual(values, ['a', 'b', 'c'])).toBe(true) + expect(undoManager.undoDescription).toBe('insert c') + expect(undoManager.redoDescription).toBe(undefined) + + await undoManager.clear() + expect(undoManager.canUndo).toBe(false) + expect(undoManager.canRedo).toBe(false) + expect(undoManager.undoDescription).toBe(undefined) + expect(undoManager.redoDescription).toBe(undefined) +})