From 37fd30413bfae96fb4a3d6f04ac112c3fb394d6c Mon Sep 17 00:00:00 2001 From: Chen-I Lim Date: Thu, 12 Nov 2020 10:16:59 -0800 Subject: [PATCH] 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}