Server generated ids (#1667)
* Adds server ID generation on the insert blocks endpoint * Fix linter * Fix server linter * Fix integration tests * Update endpoint docs * Update code to use the BlockType2IDType function when generating new IDs * Handle new block ids on boards, templates and views creation Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
This commit is contained in:
parent
581ae7b97a
commit
fa36e092bb
12 changed files with 421 additions and 109 deletions
|
@ -328,7 +328,9 @@ func stampModificationMetadata(r *http.Request, blocks []model.Block, auditRec *
|
|||
func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
// swagger:operation POST /api/v1/workspaces/{workspaceID}/blocks updateBlocks
|
||||
//
|
||||
// Insert or update blocks
|
||||
// Insert blocks. The specified IDs will only be used to link
|
||||
// blocks with existing ones, the rest will be replaced by server
|
||||
// generated IDs
|
||||
//
|
||||
// ---
|
||||
// produces:
|
||||
|
@ -352,6 +354,10 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
|||
// responses:
|
||||
// '200':
|
||||
// description: success
|
||||
// schema:
|
||||
// items:
|
||||
// $ref: '#/definitions/Block'
|
||||
// type: array
|
||||
// default:
|
||||
// description: internal error
|
||||
// schema:
|
||||
|
@ -398,6 +404,8 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
blocks = model.GenerateBlockIDs(blocks)
|
||||
|
||||
auditRec := a.makeAuditRecord(r, "postBlocks", audit.Fail)
|
||||
defer a.audit.LogRecord(audit.LevelModify, auditRec)
|
||||
|
||||
|
@ -406,14 +414,21 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
|||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
|
||||
err = a.app.InsertBlocks(*container, blocks, session.UserID, true)
|
||||
newBlocks, err := a.app.InsertBlocks(*container, blocks, session.UserID, true)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Debug("POST Blocks", mlog.Int("block_count", len(blocks)))
|
||||
jsonStringResponse(w, http.StatusOK, "{}")
|
||||
|
||||
json, err := json.Marshal(newBlocks)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusOK, json)
|
||||
|
||||
auditRec.AddMeta("blockCount", len(blocks))
|
||||
auditRec.Success()
|
||||
|
@ -912,7 +927,7 @@ func (a *API) handleImport(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
ctx := r.Context()
|
||||
session := ctx.Value(sessionContextKey).(*model.Session)
|
||||
err = a.app.InsertBlocks(*container, blocks, session.UserID, false)
|
||||
_, err = a.app.InsertBlocks(*container, model.GenerateBlockIDs(blocks), session.UserID, false)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
|
|
|
@ -73,12 +73,12 @@ func (a *App) InsertBlock(c store.Container, block model.Block, userID string) e
|
|||
return err
|
||||
}
|
||||
|
||||
func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID string, allowNotifications bool) error {
|
||||
func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID string, allowNotifications bool) ([]model.Block, error) {
|
||||
needsNotify := make([]model.Block, 0, len(blocks))
|
||||
for i := range blocks {
|
||||
err := a.store.InsertBlock(c, &blocks[i], userID)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
blocks[i].WorkspaceID = c.WorkspaceID
|
||||
needsNotify = append(needsNotify, blocks[i])
|
||||
|
@ -97,7 +97,7 @@ func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID strin
|
|||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
func (a *App) GetSubTree(c store.Container, blockID string, levels int) ([]model.Block, error) {
|
||||
|
|
|
@ -184,14 +184,14 @@ func (c *Client) PatchBlock(blockID string, blockPatch *model.BlockPatch) (bool,
|
|||
return true, BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) InsertBlocks(blocks []model.Block) (bool, *Response) {
|
||||
func (c *Client) InsertBlocks(blocks []model.Block) ([]model.Block, *Response) {
|
||||
r, err := c.DoAPIPost(c.GetBlocksRoute(), toJSON(blocks))
|
||||
if err != nil {
|
||||
return false, BuildErrorResponse(r, err)
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
return true, BuildResponse(r)
|
||||
return model.BlocksFromJSON(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteBlock(blockID string) (bool, *Response) {
|
||||
|
|
|
@ -2,6 +2,7 @@ package integrationtests
|
|||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
|
@ -17,26 +18,29 @@ func TestGetBlocks(t *testing.T) {
|
|||
require.NoError(t, resp.Error)
|
||||
initialCount := len(blocks)
|
||||
|
||||
blockID1 := utils.NewID(utils.IDTypeBlock)
|
||||
blockID2 := utils.NewID(utils.IDTypeBlock)
|
||||
initialID1 := utils.NewID(utils.IDTypeBlock)
|
||||
initialID2 := utils.NewID(utils.IDTypeBlock)
|
||||
newBlocks := []model.Block{
|
||||
{
|
||||
ID: blockID1,
|
||||
RootID: blockID1,
|
||||
ID: initialID1,
|
||||
RootID: initialID1,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 1,
|
||||
Type: model.TypeBoard,
|
||||
},
|
||||
{
|
||||
ID: blockID2,
|
||||
RootID: blockID2,
|
||||
ID: initialID2,
|
||||
RootID: initialID2,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 1,
|
||||
Type: model.TypeBoard,
|
||||
},
|
||||
}
|
||||
_, resp = th.Client.InsertBlocks(newBlocks)
|
||||
newBlocks, resp = th.Client.InsertBlocks(newBlocks)
|
||||
require.NoError(t, resp.Error)
|
||||
require.Len(t, newBlocks, 2)
|
||||
blockID1 := newBlocks[0].ID
|
||||
blockID2 := newBlocks[1].ID
|
||||
|
||||
blocks, resp = th.Client.GetBlocks()
|
||||
require.NoError(t, resp.Error)
|
||||
|
@ -58,22 +62,25 @@ func TestPostBlock(t *testing.T) {
|
|||
require.NoError(t, resp.Error)
|
||||
initialCount := len(blocks)
|
||||
|
||||
blockID1 := utils.NewID(utils.IDTypeBlock)
|
||||
blockID2 := utils.NewID(utils.IDTypeBlock)
|
||||
blockID3 := utils.NewID(utils.IDTypeBlock)
|
||||
var blockID1 string
|
||||
var blockID2 string
|
||||
var blockID3 string
|
||||
|
||||
t.Run("Create a single block", func(t *testing.T) {
|
||||
initialID1 := utils.NewID(utils.IDTypeBlock)
|
||||
block := model.Block{
|
||||
ID: blockID1,
|
||||
RootID: blockID1,
|
||||
ID: initialID1,
|
||||
RootID: initialID1,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 1,
|
||||
Type: model.TypeBoard,
|
||||
Title: "New title",
|
||||
}
|
||||
|
||||
_, resp := th.Client.InsertBlocks([]model.Block{block})
|
||||
newBlocks, resp := th.Client.InsertBlocks([]model.Block{block})
|
||||
require.NoError(t, resp.Error)
|
||||
require.Len(t, newBlocks, 1)
|
||||
blockID1 = newBlocks[0].ID
|
||||
|
||||
blocks, resp := th.Client.GetBlocks()
|
||||
require.NoError(t, resp.Error)
|
||||
|
@ -87,25 +94,32 @@ func TestPostBlock(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("Create a couple of blocks in the same call", func(t *testing.T) {
|
||||
initialID2 := utils.NewID(utils.IDTypeBlock)
|
||||
initialID3 := utils.NewID(utils.IDTypeBlock)
|
||||
newBlocks := []model.Block{
|
||||
{
|
||||
ID: blockID2,
|
||||
RootID: blockID2,
|
||||
ID: initialID2,
|
||||
RootID: initialID2,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 1,
|
||||
Type: model.TypeBoard,
|
||||
},
|
||||
{
|
||||
ID: blockID3,
|
||||
RootID: blockID3,
|
||||
ID: initialID3,
|
||||
RootID: initialID3,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 1,
|
||||
Type: model.TypeBoard,
|
||||
},
|
||||
}
|
||||
|
||||
_, resp := th.Client.InsertBlocks(newBlocks)
|
||||
newBlocks, resp := th.Client.InsertBlocks(newBlocks)
|
||||
require.NoError(t, resp.Error)
|
||||
require.Len(t, newBlocks, 2)
|
||||
blockID2 = newBlocks[0].ID
|
||||
blockID3 = newBlocks[1].ID
|
||||
require.NotEqual(t, initialID2, blockID2)
|
||||
require.NotEqual(t, initialID3, blockID3)
|
||||
|
||||
blocks, resp := th.Client.GetBlocks()
|
||||
require.NoError(t, resp.Error)
|
||||
|
@ -120,7 +134,7 @@ func TestPostBlock(t *testing.T) {
|
|||
require.Contains(t, blockIDs, blockID3)
|
||||
})
|
||||
|
||||
t.Run("Update a block", func(t *testing.T) {
|
||||
t.Run("Update a block should not be possible through the insert endpoint", func(t *testing.T) {
|
||||
block := model.Block{
|
||||
ID: blockID1,
|
||||
RootID: blockID1,
|
||||
|
@ -130,21 +144,24 @@ func TestPostBlock(t *testing.T) {
|
|||
Title: "Updated title",
|
||||
}
|
||||
|
||||
_, resp := th.Client.InsertBlocks([]model.Block{block})
|
||||
newBlocks, resp := th.Client.InsertBlocks([]model.Block{block})
|
||||
require.NoError(t, resp.Error)
|
||||
require.Len(t, newBlocks, 1)
|
||||
blockID4 := newBlocks[0].ID
|
||||
require.NotEqual(t, blockID1, blockID4)
|
||||
|
||||
blocks, resp := th.Client.GetBlocks()
|
||||
require.NoError(t, resp.Error)
|
||||
require.Len(t, blocks, initialCount+3)
|
||||
require.Len(t, blocks, initialCount+4)
|
||||
|
||||
var updatedBlock model.Block
|
||||
var block4 model.Block
|
||||
for _, b := range blocks {
|
||||
if b.ID == blockID1 {
|
||||
updatedBlock = b
|
||||
if b.ID == blockID4 {
|
||||
block4 = b
|
||||
}
|
||||
}
|
||||
require.NotNil(t, updatedBlock)
|
||||
require.Equal(t, "Updated title", updatedBlock.Title)
|
||||
require.NotNil(t, block4)
|
||||
require.Equal(t, "Updated title", block4.Title)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -152,11 +169,11 @@ func TestPatchBlock(t *testing.T) {
|
|||
th := SetupTestHelper().InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
blockID := utils.NewID(utils.IDTypeBlock)
|
||||
initialID := utils.NewID(utils.IDTypeBlock)
|
||||
|
||||
block := model.Block{
|
||||
ID: blockID,
|
||||
RootID: blockID,
|
||||
ID: initialID,
|
||||
RootID: initialID,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 1,
|
||||
Type: model.TypeBoard,
|
||||
|
@ -164,8 +181,10 @@ func TestPatchBlock(t *testing.T) {
|
|||
Fields: map[string]interface{}{"test": "test value", "test2": "test value 2"},
|
||||
}
|
||||
|
||||
_, resp := th.Client.InsertBlocks([]model.Block{block})
|
||||
newBlocks, resp := th.Client.InsertBlocks([]model.Block{block})
|
||||
require.NoError(t, resp.Error)
|
||||
require.Len(t, newBlocks, 1)
|
||||
blockID := newBlocks[0].ID
|
||||
|
||||
blocks, resp := th.Client.GetBlocks()
|
||||
require.NoError(t, resp.Error)
|
||||
|
@ -253,19 +272,24 @@ func TestDeleteBlock(t *testing.T) {
|
|||
require.NoError(t, resp.Error)
|
||||
initialCount := len(blocks)
|
||||
|
||||
blockID := utils.NewID(utils.IDTypeBlock)
|
||||
var blockID string
|
||||
t.Run("Create a block", func(t *testing.T) {
|
||||
initialID := utils.NewID(utils.IDTypeBlock)
|
||||
block := model.Block{
|
||||
ID: blockID,
|
||||
RootID: blockID,
|
||||
ID: initialID,
|
||||
RootID: initialID,
|
||||
CreateAt: 1,
|
||||
UpdateAt: 1,
|
||||
Type: model.TypeBoard,
|
||||
Title: "New title",
|
||||
}
|
||||
|
||||
_, resp := th.Client.InsertBlocks([]model.Block{block})
|
||||
newBlocks, resp := th.Client.InsertBlocks([]model.Block{block})
|
||||
require.NoError(t, resp.Error)
|
||||
require.Len(t, newBlocks, 1)
|
||||
require.NotZero(t, newBlocks[0].ID)
|
||||
require.NotEqual(t, initialID, newBlocks[0].ID)
|
||||
blockID = newBlocks[0].ID
|
||||
|
||||
blocks, resp := th.Client.GetBlocks()
|
||||
require.NoError(t, resp.Error)
|
||||
|
@ -279,6 +303,10 @@ func TestDeleteBlock(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("Delete a block", func(t *testing.T) {
|
||||
// this avoids triggering uniqueness constraint of
|
||||
// id,insert_at on block history
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
_, resp := th.Client.DeleteBlock(blockID)
|
||||
require.NoError(t, resp.Error)
|
||||
|
||||
|
|
|
@ -3,6 +3,8 @@ package model
|
|||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
)
|
||||
|
||||
// Block is the basic data unit
|
||||
|
@ -153,3 +155,59 @@ func (p *BlockPatch) Patch(block *Block) *Block {
|
|||
|
||||
return block
|
||||
}
|
||||
|
||||
// GenerateBlockIDs generates new IDs for all the blocks of the list,
|
||||
// keeping consistent any references that other blocks would made to
|
||||
// the original IDs, so a tree of blocks can get new IDs and maintain
|
||||
// its shape.
|
||||
func GenerateBlockIDs(blocks []Block) []Block {
|
||||
blockIDs := map[string]BlockType{}
|
||||
referenceIDs := map[string]bool{}
|
||||
for _, block := range blocks {
|
||||
if _, ok := blockIDs[block.ID]; !ok {
|
||||
blockIDs[block.ID] = block.Type
|
||||
}
|
||||
|
||||
if _, ok := referenceIDs[block.RootID]; !ok {
|
||||
referenceIDs[block.RootID] = true
|
||||
}
|
||||
if _, ok := referenceIDs[block.ParentID]; !ok {
|
||||
referenceIDs[block.ParentID] = true
|
||||
}
|
||||
}
|
||||
|
||||
newIDs := map[string]string{}
|
||||
for id, blockType := range blockIDs {
|
||||
for referenceID := range referenceIDs {
|
||||
if id == referenceID {
|
||||
newIDs[id] = utils.NewID(BlockType2IDType(blockType))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getExistingOrOldID := func(id string) string {
|
||||
if existingID, ok := newIDs[id]; ok {
|
||||
return existingID
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
getExistingOrNewID := func(id string) string {
|
||||
if existingID, ok := newIDs[id]; ok {
|
||||
return existingID
|
||||
}
|
||||
return utils.NewID(BlockType2IDType(blockIDs[id]))
|
||||
}
|
||||
|
||||
newBlocks := make([]Block, len(blocks))
|
||||
for i, block := range blocks {
|
||||
block.ID = getExistingOrNewID(block.ID)
|
||||
block.RootID = getExistingOrOldID(block.RootID)
|
||||
block.ParentID = getExistingOrOldID(block.ParentID)
|
||||
|
||||
newBlocks[i] = block
|
||||
}
|
||||
|
||||
return newBlocks
|
||||
}
|
||||
|
|
141
server/model/block_test.go
Normal file
141
server/model/block_test.go
Normal file
|
@ -0,0 +1,141 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateBlockIDs(t *testing.T) {
|
||||
t.Run("Should generate a new ID for a single block with no references", func(t *testing.T) {
|
||||
blockID := utils.NewID(utils.IDTypeBlock)
|
||||
blocks := []Block{{ID: blockID}}
|
||||
|
||||
blocks = GenerateBlockIDs(blocks)
|
||||
|
||||
require.NotEqual(t, blockID, blocks[0].ID)
|
||||
require.Zero(t, blocks[0].RootID)
|
||||
require.Zero(t, blocks[0].ParentID)
|
||||
})
|
||||
|
||||
t.Run("Should generate a new ID for a single block with references", func(t *testing.T) {
|
||||
blockID := utils.NewID(utils.IDTypeBlock)
|
||||
rootID := utils.NewID(utils.IDTypeBlock)
|
||||
parentID := utils.NewID(utils.IDTypeBlock)
|
||||
blocks := []Block{{ID: blockID, RootID: rootID, ParentID: parentID}}
|
||||
|
||||
blocks = GenerateBlockIDs(blocks)
|
||||
|
||||
require.NotEqual(t, blockID, blocks[0].ID)
|
||||
require.Equal(t, rootID, blocks[0].RootID)
|
||||
require.Equal(t, parentID, blocks[0].ParentID)
|
||||
})
|
||||
|
||||
t.Run("Should generate IDs and link multiple blocks with existing references", func(t *testing.T) {
|
||||
blockID1 := utils.NewID(utils.IDTypeBlock)
|
||||
rootID1 := utils.NewID(utils.IDTypeBlock)
|
||||
parentID1 := utils.NewID(utils.IDTypeBlock)
|
||||
block1 := Block{ID: blockID1, RootID: rootID1, ParentID: parentID1}
|
||||
|
||||
blockID2 := utils.NewID(utils.IDTypeBlock)
|
||||
rootID2 := blockID1
|
||||
parentID2 := utils.NewID(utils.IDTypeBlock)
|
||||
block2 := Block{ID: blockID2, RootID: rootID2, ParentID: parentID2}
|
||||
|
||||
blocks := []Block{block1, block2}
|
||||
|
||||
blocks = GenerateBlockIDs(blocks)
|
||||
|
||||
require.NotEqual(t, blockID1, blocks[0].ID)
|
||||
require.Equal(t, rootID1, blocks[0].RootID)
|
||||
require.Equal(t, parentID1, blocks[0].ParentID)
|
||||
|
||||
require.NotEqual(t, blockID2, blocks[1].ID)
|
||||
require.NotEqual(t, rootID2, blocks[1].RootID)
|
||||
require.Equal(t, parentID2, blocks[1].ParentID)
|
||||
|
||||
// blockID1 was referenced, so it should still be after the ID
|
||||
// changes
|
||||
require.Equal(t, blocks[0].ID, blocks[1].RootID)
|
||||
})
|
||||
|
||||
t.Run("Should generate new IDs but not modify nonexisting references", func(t *testing.T) {
|
||||
blockID1 := utils.NewID(utils.IDTypeBlock)
|
||||
rootID1 := ""
|
||||
parentID1 := utils.NewID(utils.IDTypeBlock)
|
||||
block1 := Block{ID: blockID1, RootID: rootID1, ParentID: parentID1}
|
||||
|
||||
blockID2 := utils.NewID(utils.IDTypeBlock)
|
||||
rootID2 := utils.NewID(utils.IDTypeBlock)
|
||||
parentID2 := ""
|
||||
block2 := Block{ID: blockID2, RootID: rootID2, ParentID: parentID2}
|
||||
|
||||
blocks := []Block{block1, block2}
|
||||
|
||||
blocks = GenerateBlockIDs(blocks)
|
||||
|
||||
// only the IDs should have changed
|
||||
require.NotEqual(t, blockID1, blocks[0].ID)
|
||||
require.Zero(t, blocks[0].RootID)
|
||||
require.Equal(t, parentID1, blocks[0].ParentID)
|
||||
|
||||
require.NotEqual(t, blockID2, blocks[1].ID)
|
||||
require.Equal(t, rootID2, blocks[1].RootID)
|
||||
require.Zero(t, blocks[1].ParentID)
|
||||
})
|
||||
|
||||
t.Run("Should modify correctly multiple blocks with existing and nonexisting references", func(t *testing.T) {
|
||||
blockID1 := utils.NewID(utils.IDTypeBlock)
|
||||
rootID1 := utils.NewID(utils.IDTypeBlock)
|
||||
parentID1 := utils.NewID(utils.IDTypeBlock)
|
||||
block1 := Block{ID: blockID1, RootID: rootID1, ParentID: parentID1}
|
||||
|
||||
// linked to 1
|
||||
blockID2 := utils.NewID(utils.IDTypeBlock)
|
||||
rootID2 := blockID1
|
||||
parentID2 := utils.NewID(utils.IDTypeBlock)
|
||||
block2 := Block{ID: blockID2, RootID: rootID2, ParentID: parentID2}
|
||||
|
||||
// linked to 2
|
||||
blockID3 := utils.NewID(utils.IDTypeBlock)
|
||||
rootID3 := blockID2
|
||||
parentID3 := utils.NewID(utils.IDTypeBlock)
|
||||
block3 := Block{ID: blockID3, RootID: rootID3, ParentID: parentID3}
|
||||
|
||||
// linked to 1
|
||||
blockID4 := utils.NewID(utils.IDTypeBlock)
|
||||
rootID4 := blockID1
|
||||
parentID4 := utils.NewID(utils.IDTypeBlock)
|
||||
block4 := Block{ID: blockID4, RootID: rootID4, ParentID: parentID4}
|
||||
|
||||
// blocks are shuffled
|
||||
blocks := []Block{block4, block2, block1, block3}
|
||||
|
||||
blocks = GenerateBlockIDs(blocks)
|
||||
|
||||
// block 1
|
||||
require.NotEqual(t, blockID1, blocks[2].ID)
|
||||
require.Equal(t, rootID1, blocks[2].RootID)
|
||||
require.Equal(t, parentID1, blocks[2].ParentID)
|
||||
|
||||
// block 2
|
||||
require.NotEqual(t, blockID2, blocks[1].ID)
|
||||
require.NotEqual(t, rootID2, blocks[1].RootID)
|
||||
require.Equal(t, blocks[2].ID, blocks[1].RootID) // link to 1
|
||||
require.Equal(t, parentID2, blocks[1].ParentID)
|
||||
|
||||
// block 3
|
||||
require.NotEqual(t, blockID3, blocks[3].ID)
|
||||
require.NotEqual(t, rootID3, blocks[3].RootID)
|
||||
require.Equal(t, blocks[1].ID, blocks[3].RootID) // link to 2
|
||||
require.Equal(t, parentID3, blocks[3].ParentID)
|
||||
|
||||
// block 4
|
||||
require.NotEqual(t, blockID4, blocks[0].ID)
|
||||
require.NotEqual(t, rootID4, blocks[0].RootID)
|
||||
require.Equal(t, blocks[2].ID, blocks[0].RootID) // link to 1
|
||||
require.Equal(t, parentID4, blocks[0].ParentID)
|
||||
})
|
||||
}
|
|
@ -59,5 +59,52 @@ function createBlock(block?: Block): Block {
|
|||
}
|
||||
}
|
||||
|
||||
// createPatchesFromBlock creates two BlockPatch instances, one that
|
||||
// contains the delta to update the block and another one for the undo
|
||||
// action, in case it happens
|
||||
function createPatchesFromBlocks(newBlock: Block, oldBlock: Block): BlockPatch[] {
|
||||
const oldDeletedFields = [] as string[]
|
||||
const newUpdatedFields = Object.keys(newBlock.fields).reduce((acc, val): Record<string, any> => {
|
||||
// the field is in both old and new, so it is part of the new
|
||||
// patch
|
||||
if (val in oldBlock.fields) {
|
||||
acc[val] = newBlock.fields[val]
|
||||
} else {
|
||||
// the field is only in the new block, so we set it to be
|
||||
// removed in the undo patch
|
||||
oldDeletedFields.push(val)
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
|
||||
const newDeletedFields = [] as string[]
|
||||
const oldUpdatedFields = Object.keys(oldBlock.fields).reduce((acc, val): Record<string, any> => {
|
||||
// the field is in both, so in this case we set the old one to
|
||||
// be applied for the undo patch
|
||||
if (val in newBlock.fields) {
|
||||
acc[val] = oldBlock.fields[val]
|
||||
} else {
|
||||
// the field is only on the old block, which means the
|
||||
// update patch should remove it
|
||||
newDeletedFields.push(val)
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
|
||||
// ToDo: add tests
|
||||
return [
|
||||
{
|
||||
...newBlock as BlockPatch,
|
||||
updatedFields: newUpdatedFields,
|
||||
deletedFields: oldDeletedFields,
|
||||
},
|
||||
{
|
||||
...oldBlock as BlockPatch,
|
||||
updatedFields: oldUpdatedFields,
|
||||
deletedFields: newDeletedFields,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export type {ContentBlockTypes, BlockTypes}
|
||||
export {blockTypes, contentBlockTypes, Block, BlockPatch, createBlock}
|
||||
export {blockTypes, contentBlockTypes, Block, BlockPatch, createBlock, createPatchesFromBlocks}
|
||||
|
|
|
@ -6,6 +6,7 @@ import {injectIntl, IntlShape} from 'react-intl'
|
|||
import {connect} from 'react-redux'
|
||||
import Hotkeys from 'react-hot-keys'
|
||||
|
||||
import {Block} from '../blocks/block'
|
||||
import {BlockIcons} from '../blockIcons'
|
||||
import {Card, createCard} from '../blocks/card'
|
||||
import {Board, IPropertyTemplate, IPropertyOption, BoardGroup} from '../blocks/board'
|
||||
|
@ -252,14 +253,14 @@ class CenterPanel extends React.Component<Props, State> {
|
|||
card.fields.icon = BlockIcons.shared.randomIcon()
|
||||
}
|
||||
mutator.performAsUndoGroup(async () => {
|
||||
await mutator.insertBlock(
|
||||
const newCard = await mutator.insertBlock(
|
||||
card,
|
||||
'add card',
|
||||
async () => {
|
||||
async (block: Block) => {
|
||||
if (show) {
|
||||
this.props.addCard(card)
|
||||
this.props.updateView({...activeView, fields: {...activeView.fields, cardOrder: [...activeView.fields.cardOrder, card.id]}})
|
||||
this.showCard(card.id)
|
||||
this.props.addCard(createCard(block))
|
||||
this.props.updateView({...activeView, fields: {...activeView.fields, cardOrder: [...activeView.fields.cardOrder, block.id]}})
|
||||
this.showCard(block.id)
|
||||
} else {
|
||||
// Focus on this card's title inline on next render
|
||||
this.setState({cardIdToFocusOnRender: card.id})
|
||||
|
@ -270,7 +271,7 @@ class CenterPanel extends React.Component<Props, State> {
|
|||
this.showCard(undefined)
|
||||
},
|
||||
)
|
||||
await mutator.changeViewCardOrder(activeView, [...activeView.fields.cardOrder, card.id], 'add-card')
|
||||
await mutator.changeViewCardOrder(activeView, [...activeView.fields.cardOrder, newCard.id], 'add-card')
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -285,10 +286,11 @@ class CenterPanel extends React.Component<Props, State> {
|
|||
await mutator.insertBlock(
|
||||
cardTemplate,
|
||||
'add card template',
|
||||
async () => {
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateCardTemplate, {board: board.id, view: activeView.id, card: cardTemplate.id})
|
||||
this.props.addTemplate(cardTemplate)
|
||||
this.showCard(cardTemplate.id)
|
||||
async (newBlock: Block) => {
|
||||
const newTemplate = createCard(newBlock)
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateCardTemplate, {board: board.id, view: activeView.id, card: newTemplate.id})
|
||||
this.props.addTemplate(newTemplate)
|
||||
this.showCard(newTemplate.id)
|
||||
}, async () => {
|
||||
this.showCard(undefined)
|
||||
},
|
||||
|
|
|
@ -4,6 +4,7 @@ import React, {useCallback, useEffect} from 'react'
|
|||
import {FormattedMessage, IntlShape, useIntl} from 'react-intl'
|
||||
import {generatePath, useHistory, useRouteMatch} from 'react-router-dom'
|
||||
|
||||
import {Block} from '../../blocks/block'
|
||||
import {Board, createBoard} from '../../blocks/board'
|
||||
import {createBoardView} from '../../blocks/boardView'
|
||||
import mutator from '../../mutator'
|
||||
|
@ -39,9 +40,10 @@ export const addBoardClicked = async (showBoard: (id: string) => void, intl: Int
|
|||
await mutator.insertBlocks(
|
||||
[board, view],
|
||||
'add board',
|
||||
async () => {
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoard, {board: board.id})
|
||||
showBoard(board.id)
|
||||
async (newBlocks: Block[]) => {
|
||||
const newBoardId = newBlocks[0].id
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoard, {board: newBoardId})
|
||||
showBoard(newBoardId)
|
||||
},
|
||||
async () => {
|
||||
if (oldBoardId) {
|
||||
|
@ -66,9 +68,10 @@ export const addBoardTemplateClicked = async (showBoard: (id: string) => void, i
|
|||
await mutator.insertBlocks(
|
||||
[boardTemplate, view],
|
||||
'add board template',
|
||||
async () => {
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoardTemplate, {board: boardTemplate.id})
|
||||
showBoard(boardTemplate.id)
|
||||
async (newBlocks: Block[]) => {
|
||||
const newBoardId = newBlocks[0].id
|
||||
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoardTemplate, {board: newBoardId})
|
||||
showBoard(newBoardId)
|
||||
}, async () => {
|
||||
if (activeBoardId) {
|
||||
showBoard(activeBoardId)
|
||||
|
|
|
@ -9,6 +9,7 @@ import {BoardView, createBoardView, IViewType} from '../blocks/boardView'
|
|||
import {Constants} from '../constants'
|
||||
import mutator from '../mutator'
|
||||
import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../telemetry/telemetryClient'
|
||||
import {Block} from '../blocks/block'
|
||||
import {IDType, Utils} from '../utils'
|
||||
import AddIcon from '../widgets/icons/add'
|
||||
import BoardIcon from '../widgets/icons/board'
|
||||
|
@ -49,10 +50,10 @@ const ViewMenu = React.memo((props: Props) => {
|
|||
mutator.insertBlock(
|
||||
newView,
|
||||
'duplicate view',
|
||||
async () => {
|
||||
async (block: Block) => {
|
||||
// This delay is needed because WSClient has a default 100 ms notification delay before updates
|
||||
setTimeout(() => {
|
||||
showView(newView.id)
|
||||
showView(block.id)
|
||||
}, 120)
|
||||
},
|
||||
async () => {
|
||||
|
@ -98,10 +99,10 @@ const ViewMenu = React.memo((props: Props) => {
|
|||
mutator.insertBlock(
|
||||
view,
|
||||
'add view',
|
||||
async () => {
|
||||
async (block: Block) => {
|
||||
// This delay is needed because WSClient has a default 100 ms notification delay before updates
|
||||
setTimeout(() => {
|
||||
showView(view.id)
|
||||
showView(block.id)
|
||||
}, 120)
|
||||
},
|
||||
async () => {
|
||||
|
@ -127,11 +128,11 @@ const ViewMenu = React.memo((props: Props) => {
|
|||
mutator.insertBlock(
|
||||
view,
|
||||
'add view',
|
||||
async () => {
|
||||
async (block: Block) => {
|
||||
// This delay is needed because WSClient has a default 100 ms notification delay before updates
|
||||
setTimeout(() => {
|
||||
Utils.log(`showView: ${view.id}`)
|
||||
showView(view.id)
|
||||
Utils.log(`showView: ${block.id}`)
|
||||
showView(block.id)
|
||||
}, 120)
|
||||
},
|
||||
async () => {
|
||||
|
@ -155,11 +156,11 @@ const ViewMenu = React.memo((props: Props) => {
|
|||
mutator.insertBlock(
|
||||
view,
|
||||
'add view',
|
||||
async () => {
|
||||
async (block: Block) => {
|
||||
// This delay is needed because WSClient has a default 100 ms notification delay before updates
|
||||
setTimeout(() => {
|
||||
Utils.log(`showView: ${view.id}`)
|
||||
showView(view.id)
|
||||
Utils.log(`showView: ${block.id}`)
|
||||
showView(block.id)
|
||||
}, 120)
|
||||
},
|
||||
async () => {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {BlockIcons} from './blockIcons'
|
||||
import {Block} from './blocks/block'
|
||||
import {Block, BlockPatch, createPatchesFromBlocks} from './blocks/block'
|
||||
import {Board, IPropertyOption, IPropertyTemplate, PropertyType, createBoard} from './blocks/board'
|
||||
import {BoardView, ISortOption, createBoardView, KanbanCalculationFields} from './blocks/boardView'
|
||||
import {Card, createCard} from './blocks/card'
|
||||
|
@ -50,12 +50,13 @@ class Mutator {
|
|||
}
|
||||
|
||||
async updateBlock(newBlock: Block, oldBlock: Block, description: string): Promise<void> {
|
||||
const [updatePatch, undoPatch] = createPatchesFromBlocks(newBlock, oldBlock)
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
await octoClient.updateBlock(newBlock)
|
||||
await octoClient.patchBlock(newBlock.id, updatePatch)
|
||||
},
|
||||
async () => {
|
||||
await octoClient.updateBlock(oldBlock)
|
||||
await octoClient.patchBlock(oldBlock.id, undoPatch)
|
||||
},
|
||||
description,
|
||||
this.undoGroupId,
|
||||
|
@ -63,43 +64,67 @@ class Mutator {
|
|||
}
|
||||
|
||||
private async updateBlocks(newBlocks: Block[], oldBlocks: Block[], description: string): Promise<void> {
|
||||
await undoManager.perform(
|
||||
if (newBlocks.length !== oldBlocks.length) {
|
||||
throw new Error('new and old blocks must have the same length when updating blocks')
|
||||
}
|
||||
|
||||
const updatePatches = [] as BlockPatch[]
|
||||
const undoPatches = [] as BlockPatch[]
|
||||
|
||||
newBlocks.forEach((newBlock, i) => {
|
||||
const [updatePatch, undoPatch] = createPatchesFromBlocks(newBlock, oldBlocks[i])
|
||||
updatePatches.push(updatePatch)
|
||||
undoPatches.push(undoPatch)
|
||||
})
|
||||
|
||||
return undoManager.perform(
|
||||
async () => {
|
||||
await octoClient.updateBlocks(newBlocks)
|
||||
await Promise.all(
|
||||
updatePatches.map((patch, i) => octoClient.patchBlock(newBlocks[i].id, patch)),
|
||||
)
|
||||
},
|
||||
async () => {
|
||||
await octoClient.updateBlocks(oldBlocks)
|
||||
await Promise.all(
|
||||
undoPatches.map((patch, i) => octoClient.patchBlock(newBlocks[i].id, patch)),
|
||||
)
|
||||
},
|
||||
description,
|
||||
this.undoGroupId,
|
||||
)
|
||||
}
|
||||
|
||||
async insertBlock(block: Block, description = 'add', afterRedo?: () => Promise<void>, beforeUndo?: () => Promise<void>) {
|
||||
await undoManager.perform(
|
||||
//eslint-disable-next-line no-shadow
|
||||
async insertBlock(block: Block, description = 'add', afterRedo?: (block: Block) => Promise<void>, beforeUndo?: (block: Block) => Promise<void>): Promise<Block> {
|
||||
return undoManager.perform(
|
||||
async () => {
|
||||
await octoClient.insertBlock(block)
|
||||
await afterRedo?.()
|
||||
const res = await octoClient.insertBlock(block)
|
||||
const jsonres = await res.json()
|
||||
const newBlock = jsonres[0] as Block
|
||||
await afterRedo?.(newBlock)
|
||||
return newBlock
|
||||
},
|
||||
async () => {
|
||||
await beforeUndo?.()
|
||||
await octoClient.deleteBlock(block.id)
|
||||
async (newBlock: Block) => {
|
||||
await beforeUndo?.(newBlock)
|
||||
await octoClient.deleteBlock(newBlock.id)
|
||||
},
|
||||
description,
|
||||
this.undoGroupId,
|
||||
)
|
||||
}
|
||||
|
||||
async insertBlocks(blocks: Block[], description = 'add', afterRedo?: () => Promise<void>, beforeUndo?: () => Promise<void>) {
|
||||
await undoManager.perform(
|
||||
//eslint-disable-next-line no-shadow
|
||||
async insertBlocks(blocks: Block[], description = 'add', afterRedo?: (blocks: Block[]) => Promise<void>, beforeUndo?: () => Promise<void>) {
|
||||
return undoManager.perform(
|
||||
async () => {
|
||||
await octoClient.insertBlocks(blocks)
|
||||
await afterRedo?.()
|
||||
const res = await octoClient.insertBlocks(blocks)
|
||||
const newBlocks = (await res.json()) as Block[]
|
||||
await afterRedo?.(newBlocks)
|
||||
return newBlocks
|
||||
},
|
||||
async () => {
|
||||
async (newBlocks: Block[]) => {
|
||||
await beforeUndo?.()
|
||||
const awaits = []
|
||||
for (const block of blocks) {
|
||||
for (const block of newBlocks) {
|
||||
awaits.push(octoClient.deleteBlock(block.id))
|
||||
}
|
||||
await Promise.all(awaits)
|
||||
|
@ -639,8 +664,8 @@ class Mutator {
|
|||
await this.insertBlocks(
|
||||
newBlocks,
|
||||
description,
|
||||
async () => {
|
||||
await afterRedo?.(newCard.id)
|
||||
async (respBlocks: Block[]) => {
|
||||
await afterRedo?.(respBlocks[0].id)
|
||||
},
|
||||
beforeUndo,
|
||||
)
|
||||
|
@ -668,15 +693,15 @@ class Mutator {
|
|||
// Board from template
|
||||
}
|
||||
newBoard.fields.isTemplate = asTemplate
|
||||
await this.insertBlocks(
|
||||
const createdBlocks = await this.insertBlocks(
|
||||
newBlocks,
|
||||
description,
|
||||
async () => {
|
||||
await afterRedo?.(newBoard.id)
|
||||
async (respBlocks: Block[]) => {
|
||||
await afterRedo?.(respBlocks[0].id)
|
||||
},
|
||||
beforeUndo,
|
||||
)
|
||||
return [newBlocks, newBoard.id]
|
||||
return [createdBlocks, createdBlocks[0].id]
|
||||
}
|
||||
|
||||
async duplicateFromRootBoard(
|
||||
|
@ -701,15 +726,15 @@ class Mutator {
|
|||
// Board from template
|
||||
}
|
||||
newBoard.fields.isTemplate = asTemplate
|
||||
await this.insertBlocks(
|
||||
const createdBlocks = await this.insertBlocks(
|
||||
newBlocks,
|
||||
description,
|
||||
async () => {
|
||||
await afterRedo?.(newBoard.id)
|
||||
async (respBlocks: Block[]) => {
|
||||
await afterRedo?.(respBlocks[0].id)
|
||||
},
|
||||
beforeUndo,
|
||||
)
|
||||
return [newBlocks, newBoard.id]
|
||||
return [createdBlocks, createdBlocks[0].id]
|
||||
}
|
||||
|
||||
// Other methods
|
||||
|
|
|
@ -248,12 +248,8 @@ class OctoClient {
|
|||
return fixedBlocks
|
||||
}
|
||||
|
||||
async updateBlock(block: Block): Promise<Response> {
|
||||
return this.insertBlocks([block])
|
||||
}
|
||||
|
||||
async patchBlock(blockId: string, blockPatch: BlockPatch): Promise<Response> {
|
||||
Utils.log(`patchBlocks: ${blockId} block`)
|
||||
Utils.log(`patchBlock: ${blockId} block`)
|
||||
const body = JSON.stringify(blockPatch)
|
||||
return fetch(this.getBaseURL() + this.workspacePath() + '/blocks/' + blockId, {
|
||||
method: 'PATCH',
|
||||
|
@ -262,10 +258,6 @@ class OctoClient {
|
|||
})
|
||||
}
|
||||
|
||||
async updateBlocks(blocks: Block[]): Promise<Response> {
|
||||
return this.insertBlocks(blocks)
|
||||
}
|
||||
|
||||
async deleteBlock(blockId: string): Promise<Response> {
|
||||
Utils.log(`deleteBlock: ${blockId}`)
|
||||
return fetch(this.getBaseURL() + this.workspacePath() + `/blocks/${encodeURIComponent(blockId)}`, {
|
||||
|
|
Loading…
Reference in a new issue