Duplicate board

This commit is contained in:
Chen-I Lim 2020-11-12 10:16:59 -08:00
parent 937af1e349
commit 37fd30413b
10 changed files with 159 additions and 26 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -106,9 +106,7 @@ class BoardCard extends React.Component<BoardCardProps, BoardCardState> {
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)
}}
/>
</Menu>

View file

@ -120,7 +120,7 @@ class Sidebar extends React.Component<Props, State> {
<IconButton icon={<OptionsIcon/>}/>
<Menu>
<Menu.Text
id='delete'
id='deleteBoard'
name={intl.formatMessage({id: 'Sidebar.delete-board', defaultMessage: 'Delete Board'})}
icon={<DeleteIcon/>}
onClick={async () => {
@ -137,6 +137,23 @@ class Sidebar extends React.Component<Props, State> {
)
}}
/>
<Menu.Text
id='duplicateBoard'
name={intl.formatMessage({id: 'Sidebar.duplicate-board', defaultMessage: 'Duplicate Board'})}
onClick={async () => {
await mutator.duplicateBoard(
board.id,
'duplicate board',
async (newBoardId) => {
newBoardId && this.props.showBoard(newBoardId)
},
async () => {
this.props.showBoard(board.id)
},
)
}}
/>
</Menu>
</MenuWrapper>
</div>

View file

@ -26,5 +26,6 @@
.Editable {
margin-bottom: 0px;
flex-grow: 1;
}
}

View file

@ -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<void>, beforeUndo?: () => Promise<void>): 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<void>, beforeUndo?: () => Promise<void>): 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

View file

@ -14,8 +14,8 @@ class OctoClient {
Utils.log(`OctoClient serverUrl: ${this.serverUrl}`)
}
async getSubtree(rootId?: string): Promise<IBlock[]> {
const path = `/api/v1/blocks/${rootId}/subtree`
async getSubtree(rootId?: string, levels = 2): Promise<IBlock[]> {
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<Response> {
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', {

View file

@ -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<Record<string, string>>] {
const idMap: Record<string, string> = {}
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}