1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-11-24 08:22:29 +02:00

Hide board feature (#4409)

* WIP

* Added migrations

* Updating store method

* WIP

* WIP

* Updated DND

* WIP

* WIP

* WIP

* WIP

* WIP

* wip

* WIP

* Adding new DB tool

* Used migration functions in new migrations

* Unique constraint migration

* Unique constraint migration

* Added SQLITE migrations

* Added SQLITE support in few more migrations

* Added SQLITE support in few more migrations

* WIP

* Used old-fashioned way to add unique constraint

* Using oldsqlite method

* Using oldsqlite method

* Fixed all store and app layer tests

* fixed integration tests

* test and lint fix

* Updated migration for MySQL and Postgres on personal server

* Types fix

* sqlite fix

* fix typo

* misc cleanup

* added new tests

* added new tests

* de-duping input for postgres

* integration tests, rmeoved uneeded migration

* Added some migration tests

* Added some migration tests

* Fixed a test

* completed migration tests

* completed migration tests

* Removed leftover debug statements

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Harshil Sharma 2023-01-24 15:41:54 +05:30 committed by GitHub
parent d6207dde6c
commit 03f4717e96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 1579 additions and 330 deletions

View File

@ -20,6 +20,8 @@ func (a *API) registerCategoriesRoutes(r *mux.Router) {
r.HandleFunc("/teams/{teamID}/categories", a.sessionRequired(a.handleGetUserCategoryBoards)).Methods(http.MethodGet)
r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/reorder", a.sessionRequired(a.handleReorderCategoryBoards)).Methods(http.MethodPut)
r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/{boardID}", a.sessionRequired(a.handleUpdateCategoryBoard)).Methods(http.MethodPost)
r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/{boardID}/hide", a.sessionRequired(a.handleHideBoard)).Methods(http.MethodPut)
r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/{boardID}/unhide", a.sessionRequired(a.handleUnhideBoard)).Methods(http.MethodPut)
}
func (a *API) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
@ -355,7 +357,7 @@ func (a *API) handleUpdateCategoryBoard(w http.ResponseWriter, r *http.Request)
userID := session.UserID
// TODO: Check the category and the team matches
err := a.app.AddUpdateUserCategoryBoard(teamID, userID, map[string]string{boardID: categoryID})
err := a.app.AddUpdateUserCategoryBoard(teamID, userID, categoryID, []string{boardID})
if err != nil {
a.errorResponse(w, r, err)
return
@ -521,3 +523,125 @@ func (a *API) handleReorderCategoryBoards(w http.ResponseWriter, r *http.Request
jsonBytesResponse(w, http.StatusOK, data)
auditRec.Success()
}
func (a *API) handleHideBoard(w http.ResponseWriter, r *http.Request) {
// swagger:operation POST /teams/{teamID}/categories/{categoryID}/boards/{boardID}/hide hideBoard
//
// Hide the specified board for the user
//
// ---
// produces:
// - application/json
// parameters:
// - name: teamID
// in: path
// description: Team ID
// required: true
// type: string
// - name: categoryID
// in: path
// description: Category ID to which the board to be hidden belongs to
// required: true
// type: string
// - name: boardID
// in: path
// description: ID of board to be hidden
// required: true
// type: string
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// "$ref": "#/definitions/Category"
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
userID := getUserID(r)
vars := mux.Vars(r)
teamID := vars["teamID"]
boardID := vars["boardID"]
categoryID := vars["categoryID"]
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
a.errorResponse(w, r, model.NewErrPermission("access denied to category"))
return
}
auditRec := a.makeAuditRecord(r, "hideBoard", audit.Fail)
defer a.audit.LogRecord(audit.LevelModify, auditRec)
auditRec.AddMeta("board_id", boardID)
auditRec.AddMeta("team_id", teamID)
auditRec.AddMeta("category_id", categoryID)
if err := a.app.SetBoardVisibility(teamID, userID, categoryID, boardID, false); err != nil {
a.errorResponse(w, r, err)
return
}
jsonStringResponse(w, http.StatusOK, "{}")
auditRec.Success()
}
func (a *API) handleUnhideBoard(w http.ResponseWriter, r *http.Request) {
// swagger:operation POST /teams/{teamID}/categories/{categoryID}/boards/{boardID}/hide unhideBoard
//
// Unhides the specified board for the user
//
// ---
// produces:
// - application/json
// parameters:
// - name: teamID
// in: path
// description: Team ID
// required: true
// type: string
// - name: categoryID
// in: path
// description: Category ID to which the board to be unhidden belongs to
// required: true
// type: string
// - name: boardID
// in: path
// description: ID of board to be unhidden
// required: true
// type: string
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// "$ref": "#/definitions/Category"
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
userID := getUserID(r)
vars := mux.Vars(r)
teamID := vars["teamID"]
boardID := vars["boardID"]
categoryID := vars["categoryID"]
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
a.errorResponse(w, r, model.NewErrPermission("access denied to category"))
return
}
auditRec := a.makeAuditRecord(r, "unhideBoard", audit.Fail)
defer a.audit.LogRecord(audit.LevelModify, auditRec)
auditRec.AddMeta("boardID", boardID)
if err := a.app.SetBoardVisibility(teamID, userID, categoryID, boardID, true); err != nil {
a.errorResponse(w, r, err)
return
}
jsonStringResponse(w, http.StatusOK, "{}")
auditRec.Success()
}

View File

@ -154,8 +154,8 @@ func (a *App) setBoardCategoryFromSource(sourceBoardID, destinationBoardID, user
var destinationCategoryID string
for _, categoryBoard := range userCategoryBoards {
for _, boardID := range categoryBoard.BoardIDs {
if boardID == sourceBoardID {
for _, metadata := range categoryBoard.BoardMetadata {
if metadata.BoardID == sourceBoardID {
// category found!
destinationCategoryID = categoryBoard.ID
break
@ -175,7 +175,7 @@ func (a *App) setBoardCategoryFromSource(sourceBoardID, destinationBoardID, user
// now that we have source board's category,
// we send destination board to the same category
return a.AddUpdateUserCategoryBoard(teamID, userID, map[string]string{destinationBoardID: destinationCategoryID})
return a.AddUpdateUserCategoryBoard(teamID, userID, destinationCategoryID, []string{destinationBoardID})
}
func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) {
@ -329,12 +329,12 @@ func (a *App) addBoardsToDefaultCategory(userID, teamID string, boards []*model.
return fmt.Errorf("%w userID: %s", errNoDefaultCategoryFound, userID)
}
boardCategoryMapping := map[string]string{}
for _, board := range boards {
boardCategoryMapping[board.ID] = defaultCategoryID
boardIDs := make([]string, len(boards))
for i := range boards {
boardIDs[i] = boards[i].ID
}
if err := a.AddUpdateUserCategoryBoard(teamID, userID, boardCategoryMapping); err != nil {
if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, boardIDs); err != nil {
return err
}

View File

@ -51,8 +51,8 @@ func TestAddMemberToBoard(t *testing.T) {
Type: "system",
},
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "default_category_id"}).Return(nil)
}, nil).Times(2)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", []string{"board_id_1"}).Return(nil)
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
require.NoError(t, err)
@ -125,8 +125,8 @@ func TestAddMemberToBoard(t *testing.T) {
Type: "system",
},
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "default_category_id"}).Return(nil)
}, nil).Times(2)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", []string{"board_id_1"}).Return(nil)
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
require.NoError(t, err)
@ -411,22 +411,50 @@ func TestBoardCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("test addBoardsToDefaultCategory", func(t *testing.T) {
t.Run("no boards default category exists", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
BoardIDs: []string{"board_id_1", "board_id_2"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_1"},
{BoardID: "board_id_2"},
},
},
{
Category: model.Category{ID: "category_id_2", Name: "Category 2"},
BoardIDs: []string{"board_id_3"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_3"},
},
},
{
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
BoardIDs: []string{},
BoardMetadata: []model.CategoryBoardMetadata{},
},
}, nil)
}, nil).Times(1)
// when this function is called the second time, the default category is created
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_1"},
{BoardID: "board_id_2"},
},
},
{
Category: model.Category{ID: "category_id_2", Name: "Category 2"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_3"},
},
},
{
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
BoardMetadata: []model.CategoryBoardMetadata{},
},
{
Category: model.Category{ID: "default_category_id", Type: model.CategoryTypeSystem, Name: "Boards"},
},
}, nil).Times(1)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
@ -435,10 +463,10 @@ func TestBoardCategory(t *testing.T) {
}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{
"board_id_1": "default_category_id",
"board_id_2": "default_category_id",
"board_id_3": "default_category_id",
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", []string{
"board_id_1",
"board_id_2",
"board_id_3",
}).Return(nil)
boards := []*model.Board{
@ -450,7 +478,6 @@ func TestBoardCategory(t *testing.T) {
err := th.App.addBoardsToDefaultCategory("user_id", "team_id", boards)
assert.NoError(t, err)
})
})
}
func TestDuplicateBoard(t *testing.T) {
@ -491,9 +518,9 @@ func TestDuplicateBoard(t *testing.T) {
Type: "system",
},
},
}, nil).Times(2)
}, nil).Times(3)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", utils.Anything).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "category_id_1", utils.Anything).Return(nil)
// for WS change broadcast
th.Store.EXPECT().GetMembersForBoard(utils.Anything).Return([]*model.BoardMember{}, nil).Times(2)

View File

@ -178,13 +178,12 @@ func (a *App) moveBoardsToDefaultCategory(userID, teamID, sourceCategoryID strin
return fmt.Errorf("moveBoardsToDefaultCategory: %w", errNoDefaultCategoryFound)
}
boardCategoryMapping := map[string]string{}
for _, boardID := range sourceCategoryBoards.BoardIDs {
boardCategoryMapping[boardID] = defaultCategoryID
boardIDs := make([]string, len(sourceCategoryBoards.BoardMetadata))
for i := range sourceCategoryBoards.BoardMetadata {
boardIDs[i] = sourceCategoryBoards.BoardMetadata[i].BoardID
}
if err := a.AddUpdateUserCategoryBoard(teamID, userID, boardCategoryMapping); err != nil {
if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, boardIDs); err != nil {
return fmt.Errorf("moveBoardsToDefaultCategory: %w", err)
}

View File

@ -80,7 +80,7 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
createdCategoryBoards := &model.CategoryBoards{
Category: *createdCategory,
BoardIDs: []string{},
BoardMetadata: []model.CategoryBoardMetadata{},
}
// get user's current team's baords
@ -89,6 +89,8 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
return nil, fmt.Errorf("createBoardsCategory error fetching user's team's boards: %w", err)
}
boardIDsToAdd := []string{}
for _, board := range userTeamBoards {
boardMembership, ok := boardMemberByBoardID[board.ID]
if !ok {
@ -107,8 +109,8 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
belongsToCategory := false
for _, categoryBoard := range existingCategoryBoards {
for _, boardID := range categoryBoard.BoardIDs {
if boardID == board.ID {
for _, metadata := range categoryBoard.BoardMetadata {
if metadata.BoardID == board.ID {
belongsToCategory = true
break
}
@ -122,29 +124,58 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
}
if !belongsToCategory {
if err := a.AddUpdateUserCategoryBoard(teamID, userID, map[string]string{board.ID: createdCategory.ID}); err != nil {
return nil, fmt.Errorf("createBoardsCategory failed to add category-less board to the default category, defaultCategoryID: %s, error: %w", createdCategory.ID, err)
boardIDsToAdd = append(boardIDsToAdd, board.ID)
newBoardMetadata := model.CategoryBoardMetadata{
BoardID: board.ID,
Hidden: false,
}
createdCategoryBoards.BoardMetadata = append(createdCategoryBoards.BoardMetadata, newBoardMetadata)
}
}
createdCategoryBoards.BoardIDs = append(createdCategoryBoards.BoardIDs, board.ID)
if len(boardIDsToAdd) > 0 {
if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, boardIDsToAdd); err != nil {
return nil, fmt.Errorf("createBoardsCategory failed to add category-less board to the default category, defaultCategoryID: %s, error: %w", createdCategory.ID, err)
}
}
return createdCategoryBoards, nil
}
func (a *App) AddUpdateUserCategoryBoard(teamID, userID string, boardCategoryMapping map[string]string) error {
err := a.store.AddUpdateCategoryBoard(userID, boardCategoryMapping)
func (a *App) AddUpdateUserCategoryBoard(teamID, userID, categoryID string, boardIDs []string) error {
if len(boardIDs) == 0 {
return nil
}
err := a.store.AddUpdateCategoryBoard(userID, categoryID, boardIDs)
if err != nil {
return err
}
wsPayload := make([]*model.BoardCategoryWebsocketData, len(boardCategoryMapping))
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
if err != nil {
return err
}
var updatedCategory *model.CategoryBoards
for i := range userCategoryBoards {
if userCategoryBoards[i].ID == categoryID {
updatedCategory = &userCategoryBoards[i]
break
}
}
if updatedCategory == nil {
return errCategoryNotFound
}
wsPayload := make([]*model.BoardCategoryWebsocketData, len(updatedCategory.BoardMetadata))
i := 0
for boardID, categoryID := range boardCategoryMapping {
for _, categoryBoardMetadata := range updatedCategory.BoardMetadata {
wsPayload[i] = &model.BoardCategoryWebsocketData{
BoardID: boardID,
BoardID: categoryBoardMetadata.BoardID,
CategoryID: categoryID,
Hidden: categoryBoardMetadata.Hidden,
}
i++
}
@ -198,12 +229,12 @@ func (a *App) verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID st
return fmt.Errorf("%w categoryID: %s", errCategoryNotFound, categoryID)
}
if len(targetCategoryBoards.BoardIDs) != len(newBoardsOrder) {
if len(targetCategoryBoards.BoardMetadata) != len(newBoardsOrder) {
return fmt.Errorf(
"%w length new category boards: %d, length existing category boards: %d, userID: %s, teamID: %s, categoryID: %s",
errCategoryBoardsLengthMismatch,
len(newBoardsOrder),
len(targetCategoryBoards.BoardIDs),
len(targetCategoryBoards.BoardMetadata),
userID,
teamID,
categoryID,
@ -211,8 +242,8 @@ func (a *App) verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID st
}
existingBoardMap := map[string]bool{}
for _, boardID := range targetCategoryBoards.BoardIDs {
existingBoardMap[boardID] = true
for _, metadata := range targetCategoryBoards.BoardMetadata {
existingBoardMap[metadata.BoardID] = true
}
for _, boardID := range newBoardsOrder {
@ -230,3 +261,19 @@ func (a *App) verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID st
return nil
}
func (a *App) SetBoardVisibility(teamID, userID, categoryID, boardID string, visible bool) error {
if err := a.store.SetBoardVisibility(userID, categoryID, boardID, visible); err != nil {
return fmt.Errorf("SetBoardVisibility: failed to update board visibility: %w", err)
}
a.wsAdapter.BroadcastCategoryBoardChange(teamID, userID, []*model.BoardCategoryWebsocketData{
{
BoardID: boardID,
CategoryID: categoryID,
Hidden: !visible,
},
})
return nil
}

View File

@ -14,7 +14,16 @@ func TestGetUserCategoryBoards(t *testing.T) {
defer tearDown()
t.Run("user had no default category and had boards", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil).Times(1)
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "boards_category_id",
Type: model.CategoryTypeSystem,
Name: "Boards",
},
},
}, nil).Times(1)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
@ -49,18 +58,16 @@ func TestGetUserCategoryBoards(t *testing.T) {
Synthetic: false,
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_2": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_3": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil)
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "Boards", categoryBoards[0].Name)
assert.Equal(t, 3, len(categoryBoards[0].BoardIDs))
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_1")
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_2")
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_3")
assert.Equal(t, 3, len(categoryBoards[0].BoardMetadata))
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_1", Hidden: false})
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_2", Hidden: false})
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_3", Hidden: false})
})
t.Run("user had no default category BUT had no boards", func(t *testing.T) {
@ -78,14 +85,17 @@ func TestGetUserCategoryBoards(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "Boards", categoryBoards[0].Name)
assert.Equal(t, 0, len(categoryBoards[0].BoardIDs))
assert.Equal(t, 0, len(categoryBoards[0].BoardMetadata))
})
t.Run("user already had a default Boards category with boards in it", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{Name: "Boards"},
BoardIDs: []string{"board_id_1", "board_id_2"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_1", Hidden: false},
{BoardID: "board_id_2", Hidden: false},
},
},
}, nil)
@ -93,7 +103,7 @@ func TestGetUserCategoryBoards(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "Boards", categoryBoards[0].Name)
assert.Equal(t, 2, len(categoryBoards[0].BoardIDs))
assert.Equal(t, 2, len(categoryBoards[0].BoardMetadata))
})
}
@ -116,7 +126,7 @@ func TestCreateBoardsCategory(t *testing.T) {
assert.NoError(t, err)
assert.NotNil(t, boardsCategory)
assert.Equal(t, "Boards", boardsCategory.Name)
assert.Equal(t, 0, len(boardsCategory.BoardIDs))
assert.Equal(t, 0, len(boardsCategory.BoardMetadata))
})
t.Run("user has implicit access to some board", func(t *testing.T) {
@ -150,7 +160,7 @@ func TestCreateBoardsCategory(t *testing.T) {
// there should still be no boards in the default category as
// the user had only implicit access to boards
assert.Equal(t, 0, len(boardsCategory.BoardIDs))
assert.Equal(t, 0, len(boardsCategory.BoardMetadata))
})
t.Run("user has explicit access to some board", func(t *testing.T) {
@ -185,9 +195,17 @@ func TestCreateBoardsCategory(t *testing.T) {
Synthetic: false,
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_2": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_3": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1", "board_id_2", "board_id_3"}).Return(nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{
Type: model.CategoryTypeSystem,
ID: "boards_category_id",
Name: "Boards",
},
},
}, nil)
existingCategoryBoards := []model.CategoryBoards{}
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
@ -197,7 +215,7 @@ func TestCreateBoardsCategory(t *testing.T) {
// since user has explicit access to three boards,
// they should all end up in the default category
assert.Equal(t, 3, len(boardsCategory.BoardIDs))
assert.Equal(t, 3, len(boardsCategory.BoardMetadata))
})
t.Run("user has both implicit and explicit access to some board", func(t *testing.T) {
@ -226,7 +244,17 @@ func TestCreateBoardsCategory(t *testing.T) {
Synthetic: true,
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", []string{"board_id_1"}).Return(nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{
Type: model.CategoryTypeSystem,
ID: "boards_category_id",
Name: "Boards",
},
},
}, nil)
existingCategoryBoards := []model.CategoryBoards{}
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
@ -237,7 +265,7 @@ func TestCreateBoardsCategory(t *testing.T) {
// there was only one explicit board access,
// and so only that one should end up in the
// default category
assert.Equal(t, 1, len(boardsCategory.BoardIDs))
assert.Equal(t, 1, len(boardsCategory.BoardMetadata))
})
}
@ -249,15 +277,20 @@ func TestReorderCategoryBoards(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
BoardIDs: []string{"board_id_1", "board_id_2"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_1", Hidden: false},
{BoardID: "board_id_2", Hidden: false},
},
},
{
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
BoardIDs: []string{"board_id_3"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_3", Hidden: false},
},
},
{
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
BoardIDs: []string{},
BoardMetadata: []model.CategoryBoardMetadata{},
},
}, nil)
@ -274,15 +307,21 @@ func TestReorderCategoryBoards(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
BoardIDs: []string{"board_id_1", "board_id_2", "board_id_3"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_1", Hidden: false},
{BoardID: "board_id_2", Hidden: false},
{BoardID: "board_id_3", Hidden: false},
},
},
{
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
BoardIDs: []string{"board_id_3"},
BoardMetadata: []model.CategoryBoardMetadata{
{BoardID: "board_id_3", Hidden: false},
},
},
{
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
BoardIDs: []string{},
BoardMetadata: []model.CategoryBoardMetadata{},
},
}, nil)

View File

@ -278,7 +278,7 @@ func TestDeleteCategory(t *testing.T) {
Type: "default",
Name: "Boards",
},
BoardIDs: []string{},
BoardMetadata: []model.CategoryBoardMetadata{},
},
{
Category: model.Category{
@ -289,12 +289,10 @@ func TestDeleteCategory(t *testing.T) {
Type: "custom",
Name: "Category 1",
},
BoardIDs: []string{},
BoardMetadata: []model.CategoryBoardMetadata{},
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", utils.Anything).Return(nil)
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
assert.NotNil(t, deletedCategory)
assert.NoError(t, err)
@ -351,8 +349,6 @@ func TestMoveBoardsToDefaultCategory(t *testing.T) {
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", utils.Anything).Return(nil)
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
assert.NoError(t, err)
})
@ -376,7 +372,6 @@ func TestMoveBoardsToDefaultCategory(t *testing.T) {
}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", utils.Anything).Return(nil)
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
assert.NoError(t, err)

View File

@ -47,6 +47,18 @@ func TestApp_ImportArchive(t *testing.T) {
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "user").Return(babs, nil)
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{boardMember}, nil)
// th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
// th.Store.EXPECT().GetMemberForBoard(board.ID, "user").Return(boardMember, nil)
// th.Store.EXPECT().GetUserCategoryBoards("user", "test-team").Return([]model.CategoryBoards{}, nil)
th.Store.EXPECT().GetUserCategoryBoards("user", "test-team").Return([]model.CategoryBoards{
{
Category: model.Category{
Type: "default",
Name: "Boards",
ID: "boards_category_id",
},
},
}, nil)
th.Store.EXPECT().GetUserCategoryBoards("user", "test-team")
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
@ -55,7 +67,7 @@ func TestApp_ImportArchive(t *testing.T) {
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user", "test-team", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().GetMembersForUser("user").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user", utils.Anything).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user", utils.Anything, utils.Anything).Return(nil)
err := th.App.ImportArchive(r, opts)
require.NoError(t, err, "import archive should not fail")
@ -97,7 +109,16 @@ func TestApp_ImportArchive(t *testing.T) {
th.Store.EXPECT().CreateBoardsAndBlocks(gomock.AssignableToTypeOf(&model.BoardsAndBlocks{}), "f1tydgc697fcbp8ampr6881jea").Return(babs, nil)
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{bm1, bm2, bm3}, nil)
th.Store.EXPECT().GetUserCategoryBoards("f1tydgc697fcbp8ampr6881jea", "test-team")
th.Store.EXPECT().GetUserCategoryBoards("f1tydgc697fcbp8ampr6881jea", "test-team").Return([]model.CategoryBoards{}, nil)
th.Store.EXPECT().GetUserCategoryBoards("f1tydgc697fcbp8ampr6881jea", "test-team").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "boards_category_id",
Name: "Boards",
Type: model.CategoryTypeSystem,
},
},
}, nil)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
@ -105,7 +126,7 @@ func TestApp_ImportArchive(t *testing.T) {
}, nil)
th.Store.EXPECT().GetMembersForUser("f1tydgc697fcbp8ampr6881jea").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("f1tydgc697fcbp8ampr6881jea", "test-team", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("f1tydgc697fcbp8ampr6881jea", utils.Anything).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("f1tydgc697fcbp8ampr6881jea", utils.Anything, utils.Anything).Return(nil)
th.Store.EXPECT().GetBoard(board.ID).AnyTimes().Return(board, nil)
th.Store.EXPECT().GetMemberForBoard(board.ID, "f1tydgc697fcbp8ampr6881jea").AnyTimes().Return(bm1, nil)
th.Store.EXPECT().GetMemberForBoard(board.ID, "hxxzooc3ff8cubsgtcmpn8733e").AnyTimes().Return(bm2, nil)

View File

@ -70,7 +70,7 @@ func TestPrepareOnboardingTour(t *testing.T) {
{
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
},
}, nil).Times(1)
}, nil).Times(2)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil).Times(1)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
@ -78,7 +78,7 @@ func TestPrepareOnboardingTour(t *testing.T) {
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id_1", teamID, false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_2": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", []string{"board_id_2"}).Return(nil)
teamID, boardID, err := th.App.PrepareOnboardingTour(userID, teamID)
assert.NoError(t, err)
@ -120,8 +120,8 @@ func TestCreateWelcomeBoard(t *testing.T) {
{
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
},
}, nil).Times(2)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
}, nil).Times(3)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", []string{"board_id_1"}).Return(nil)
boardID, err := th.App.createWelcomeBoard(userID, teamID)
assert.Nil(t, err)

View File

@ -986,3 +986,21 @@ func (c *Client) GetStatistics() (*model.BoardsStatistics, *Response) {
return stats, BuildResponse(r)
}
func (c *Client) HideBoard(teamID, categoryID, boardID string) *Response {
r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/"+categoryID+"/boards/"+boardID+"/hide", "")
if err != nil {
return BuildErrorResponse(r, err)
}
return BuildResponse(r)
}
func (c *Client) UnhideBoard(teamID, categoryID, boardID string) *Response {
r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/"+categoryID+"/boards/"+boardID+"/unhide", "")
if err != nil {
return BuildErrorResponse(r, err)
}
return BuildResponse(r)
}

View File

@ -2082,8 +2082,8 @@ func TestDuplicateBoard(t *testing.T) {
var duplicateBoardCategoryID string
for _, categoryBoard := range userCategoryBoards {
for _, boardID := range categoryBoard.BoardIDs {
if boardID == duplicateBoard.ID {
for _, boardMetadata := range categoryBoard.BoardMetadata {
if boardMetadata.BoardID == duplicateBoard.ID {
duplicateBoardCategoryID = categoryBoard.Category.ID
}
}

View File

@ -18,8 +18,8 @@ func TestSidebar(t *testing.T) {
categoryBoards := th.GetUserCategoryBoards("team-id")
require.Equal(t, 1, len(categoryBoards))
require.Equal(t, "Boards", categoryBoards[0].Name)
require.Equal(t, 1, len(categoryBoards[0].BoardIDs))
require.Equal(t, board.ID, categoryBoards[0].BoardIDs[0])
require.Equal(t, 1, len(categoryBoards[0].BoardMetadata))
require.Equal(t, board.ID, categoryBoards[0].BoardMetadata[0].BoardID)
// create a new category, a new board
// and move that board into the new category
@ -39,8 +39,8 @@ func TestSidebar(t *testing.T) {
// the newly created category should be the first one array
// as new categories end up on top in LHS
require.Equal(t, "Category 2", categoryBoards[0].Name)
require.Equal(t, 1, len(categoryBoards[0].BoardIDs))
require.Equal(t, board2.ID, categoryBoards[0].BoardIDs[0])
require.Equal(t, 1, len(categoryBoards[0].BoardMetadata))
require.Equal(t, board2.ID, categoryBoards[0].BoardMetadata[0].BoardID)
// now we'll delete the custom category we created, "Category 2"
// and all it's boards should get moved to the Boards category
@ -48,7 +48,51 @@ func TestSidebar(t *testing.T) {
categoryBoards = th.GetUserCategoryBoards("team-id")
require.Equal(t, 1, len(categoryBoards))
require.Equal(t, "Boards", categoryBoards[0].Name)
require.Equal(t, 2, len(categoryBoards[0].BoardIDs))
require.Contains(t, categoryBoards[0].BoardIDs, board.ID)
require.Contains(t, categoryBoards[0].BoardIDs, board2.ID)
require.Equal(t, 2, len(categoryBoards[0].BoardMetadata))
require.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: board.ID, Hidden: false})
require.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: board2.ID, Hidden: false})
}
func TestHideUnhideBoard(t *testing.T) {
th := SetupTestHelperWithToken(t).Start()
defer th.TearDown()
// we'll create a new board.
// The board should end up in a default "Boards" category
th.CreateBoard("team-id", "O")
// the created board should not be hidden
categoryBoards := th.GetUserCategoryBoards("team-id")
require.Equal(t, 1, len(categoryBoards))
require.Equal(t, "Boards", categoryBoards[0].Name)
require.Equal(t, 1, len(categoryBoards[0].BoardMetadata))
require.False(t, categoryBoards[0].BoardMetadata[0].Hidden)
// now we'll hide the board
response := th.Client.HideBoard("team-id", categoryBoards[0].ID, categoryBoards[0].BoardMetadata[0].BoardID)
th.CheckOK(response)
// verifying if the board has been marked as hidden
categoryBoards = th.GetUserCategoryBoards("team-id")
require.True(t, categoryBoards[0].BoardMetadata[0].Hidden)
// trying to hide the already hidden board.This should have no effect
response = th.Client.HideBoard("team-id", categoryBoards[0].ID, categoryBoards[0].BoardMetadata[0].BoardID)
th.CheckOK(response)
categoryBoards = th.GetUserCategoryBoards("team-id")
require.True(t, categoryBoards[0].BoardMetadata[0].Hidden)
// now we'll unhide the board
response = th.Client.UnhideBoard("team-id", categoryBoards[0].ID, categoryBoards[0].BoardMetadata[0].BoardID)
th.CheckOK(response)
// verifying
categoryBoards = th.GetUserCategoryBoards("team-id")
require.False(t, categoryBoards[0].BoardMetadata[0].Hidden)
// trying to unhide the already visible board.This should have no effect
response = th.Client.UnhideBoard("team-id", categoryBoards[0].ID, categoryBoards[0].BoardMetadata[0].BoardID)
th.CheckOK(response)
categoryBoards = th.GetUserCategoryBoards("team-id")
require.False(t, categoryBoards[0].BoardMetadata[0].Hidden)
}

View File

@ -9,7 +9,7 @@ type CategoryBoards struct {
// The IDs of boards in this category
// required: true
BoardIDs []string `json:"boardIDs"`
BoardMetadata []CategoryBoardMetadata `json:"boardMetadata"`
// The relative sort order of this board in its category
// required: true
@ -19,4 +19,10 @@ type CategoryBoards struct {
type BoardCategoryWebsocketData struct {
BoardID string `json:"boardID"`
CategoryID string `json:"categoryID"`
Hidden bool `json:"hidden"`
}
type CategoryBoardMetadata struct {
BoardID string `json:"boardID"`
Hidden bool `json:"hidden"`
}

View File

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:generate mockgen -destination=mocks/propValueResolverMock.go -package mocks . PropValueResolver
package model
import (

View File

@ -37,17 +37,17 @@ func (m *MockStore) EXPECT() *MockStoreMockRecorder {
}
// AddUpdateCategoryBoard mocks base method.
func (m *MockStore) AddUpdateCategoryBoard(arg0 string, arg1 map[string]string) error {
func (m *MockStore) AddUpdateCategoryBoard(arg0, arg1 string, arg2 []string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AddUpdateCategoryBoard", arg0, arg1)
ret := m.ctrl.Call(m, "AddUpdateCategoryBoard", arg0, arg1, arg2)
ret0, _ := ret[0].(error)
return ret0
}
// AddUpdateCategoryBoard indicates an expected call of AddUpdateCategoryBoard.
func (mr *MockStoreMockRecorder) AddUpdateCategoryBoard(arg0, arg1 interface{}) *gomock.Call {
func (mr *MockStoreMockRecorder) AddUpdateCategoryBoard(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUpdateCategoryBoard", reflect.TypeOf((*MockStore)(nil).AddUpdateCategoryBoard), arg0, arg1)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUpdateCategoryBoard", reflect.TypeOf((*MockStore)(nil).AddUpdateCategoryBoard), arg0, arg1, arg2)
}
// CanSeeUser mocks base method.
@ -1545,6 +1545,20 @@ func (mr *MockStoreMockRecorder) SendMessage(arg0, arg1, arg2 interface{}) *gomo
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMessage", reflect.TypeOf((*MockStore)(nil).SendMessage), arg0, arg1, arg2)
}
// SetBoardVisibility mocks base method.
func (m *MockStore) SetBoardVisibility(arg0, arg1, arg2 string, arg3 bool) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetBoardVisibility", arg0, arg1, arg2, arg3)
ret0, _ := ret[0].(error)
return ret0
}
// SetBoardVisibility indicates an expected call of SetBoardVisibility.
func (mr *MockStoreMockRecorder) SetBoardVisibility(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBoardVisibility", reflect.TypeOf((*MockStore)(nil).SetBoardVisibility), arg0, arg1, arg2, arg3)
}
// SetSystemSetting mocks base method.
func (m *MockStore) SetSystemSetting(arg0, arg1 string) error {
m.ctrl.T.Helper()

View File

@ -19,14 +19,14 @@ func (s *SQLStore) getUserCategoryBoards(db sq.BaseRunner, userID, teamID string
userCategoryBoards := []model.CategoryBoards{}
for _, category := range categories {
boardIDs, err := s.getCategoryBoardAttributes(db, category.ID)
boardMetadata, err := s.getCategoryBoardAttributes(db, category.ID)
if err != nil {
return nil, err
}
userCategoryBoard := model.CategoryBoards{
Category: category,
BoardIDs: boardIDs,
BoardMetadata: boardMetadata,
}
userCategoryBoards = append(userCategoryBoards, userCategoryBoard)
@ -35,13 +35,12 @@ func (s *SQLStore) getUserCategoryBoards(db sq.BaseRunner, userID, teamID string
return userCategoryBoards, nil
}
func (s *SQLStore) getCategoryBoardAttributes(db sq.BaseRunner, categoryID string) ([]string, error) {
func (s *SQLStore) getCategoryBoardAttributes(db sq.BaseRunner, categoryID string) ([]model.CategoryBoardMetadata, error) {
query := s.getQueryBuilder(db).
Select("board_id").
Select("board_id, COALESCE(hidden, false)").
From(s.tablePrefix + "category_boards").
Where(sq.Eq{
"category_id": categoryID,
"delete_at": 0,
}).
OrderBy("sort_order")
@ -54,21 +53,17 @@ func (s *SQLStore) getCategoryBoardAttributes(db sq.BaseRunner, categoryID strin
return s.categoryBoardsFromRows(rows)
}
func (s *SQLStore) addUpdateCategoryBoard(db sq.BaseRunner, userID string, boardCategoryMapping map[string]string) error {
boardIDs := []string{}
for boardID := range boardCategoryMapping {
boardIDs = append(boardIDs, boardID)
}
func (s *SQLStore) addUpdateCategoryBoard(db sq.BaseRunner, userID, categoryID string, boardIDsParam []string) error {
// we need to de-duplicate this array as Postgres failes to
// handle upsert if there are multiple incoming rows
// that conflict the same existing row.
// For example, having the entry "1" in DB and trying to upsert "1" and "1" will fail
// as there are multiple duplicates of the same "1".
//
// Source: https://stackoverflow.com/questions/42994373/postgresql-on-conflict-cannot-affect-row-a-second-time
boardIDs := utils.DedupeStringArr(boardIDsParam)
if err := s.deleteUserCategoryBoards(db, userID, boardIDs); err != nil {
return err
}
return s.addUserCategoryBoard(db, userID, boardCategoryMapping)
}
func (s *SQLStore) addUserCategoryBoard(db sq.BaseRunner, userID string, boardCategoryMapping map[string]string) error {
if len(boardCategoryMapping) == 0 {
if len(boardIDs) == 0 {
return nil
}
@ -81,14 +76,13 @@ func (s *SQLStore) addUserCategoryBoard(db sq.BaseRunner, userID string, boardCa
"board_id",
"create_at",
"update_at",
"delete_at",
"sort_order",
"hidden",
)
now := utils.GetMillis()
for boardID, categoryID := range boardCategoryMapping {
query = query.
Values(
for _, boardID := range boardIDs {
query = query.Values(
utils.NewID(utils.IDTypeNone),
userID,
categoryID,
@ -96,58 +90,48 @@ func (s *SQLStore) addUserCategoryBoard(db sq.BaseRunner, userID string, boardCa
now,
now,
0,
0,
false,
)
}
if s.dbType == model.MysqlDBType {
query = query.Suffix(
"ON DUPLICATE KEY UPDATE category_id = ?",
categoryID,
)
} else {
query = query.Suffix(
`ON CONFLICT (user_id, board_id)
DO UPDATE SET category_id = EXCLUDED.category_id, update_at = EXCLUDED.update_at`,
)
}
if _, err := query.Exec(); err != nil {
s.logger.Error("addUserCategoryBoard error", mlog.Err(err))
return err
}
return nil
}
func (s *SQLStore) deleteUserCategoryBoards(db sq.BaseRunner, userID string, boardIDs []string) error {
if len(boardIDs) == 0 {
return nil
}
_, err := s.getQueryBuilder(db).
Update(s.tablePrefix+"category_boards").
Set("delete_at", utils.GetMillis()).
Where(sq.Eq{
"user_id": userID,
"board_id": boardIDs,
"delete_at": 0,
}).Exec()
if err != nil {
s.logger.Error(
"deleteUserCategoryBoards delete error",
mlog.String("userID", userID),
mlog.Array("boardID", boardIDs),
mlog.Err(err),
return fmt.Errorf(
"store addUpdateCategoryBoard: failed to upsert user-board-category userID: %s, categoryID: %s, board_count: %d, error: %w",
userID, categoryID, len(boardIDs), err,
)
return err
}
return nil
}
func (s *SQLStore) categoryBoardsFromRows(rows *sql.Rows) ([]string, error) {
blocks := []string{}
func (s *SQLStore) categoryBoardsFromRows(rows *sql.Rows) ([]model.CategoryBoardMetadata, error) {
metadata := []model.CategoryBoardMetadata{}
for rows.Next() {
boardID := ""
if err := rows.Scan(&boardID); err != nil {
datum := model.CategoryBoardMetadata{}
err := rows.Scan(&datum.BoardID, &datum.Hidden)
if err != nil {
s.logger.Error("categoryBoardsFromRows row scan error", mlog.Err(err))
return nil, err
}
blocks = append(blocks, boardID)
metadata = append(metadata, datum)
}
return blocks, nil
return metadata, nil
}
func (s *SQLStore) reorderCategoryBoards(db sq.BaseRunner, categoryID string, newBoardsOrder []string) ([]string, error) {
@ -166,7 +150,6 @@ func (s *SQLStore) reorderCategoryBoards(db sq.BaseRunner, categoryID string, ne
Set("sort_order", updateCase).
Where(sq.Eq{
"category_id": categoryID,
"delete_at": 0,
})
if _, err := query.Exec(); err != nil {
@ -181,3 +164,28 @@ func (s *SQLStore) reorderCategoryBoards(db sq.BaseRunner, categoryID string, ne
return newBoardsOrder, nil
}
func (s *SQLStore) setBoardVisibility(db sq.BaseRunner, userID, categoryID, boardID string, visible bool) error {
query := s.getQueryBuilder(db).
Update(s.tablePrefix+"category_boards").
Set("hidden", !visible).
Where(sq.Eq{
"user_id": userID,
"category_id": categoryID,
"board_id": boardID,
})
if _, err := query.Exec(); err != nil {
s.logger.Error(
"SQLStore setBoardVisibility: failed to update board visibility",
mlog.String("user_id", userID),
mlog.String("board_id", boardID),
mlog.Bool("visible", visible),
mlog.Err(err),
)
return err
}
return nil
}

View File

@ -302,6 +302,7 @@ func (s *SQLStore) GetTemplateHelperFuncs() template.FuncMap {
"renameColumnIfNeeded": s.genRenameColumnIfNeeded,
"doesTableExist": s.doesTableExist,
"doesColumnExist": s.doesColumnExist,
"addConstraintIfNeeded": s.genAddConstraintIfNeeded,
}
return funcs
}
@ -607,6 +608,67 @@ func (s *SQLStore) doesColumnExist(tableName, columnName string) (bool, error) {
return exists, nil
}
func (s *SQLStore) genAddConstraintIfNeeded(tableName, constraintName, constraintType, constraintDefinition string) (string, error) {
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
normTableName := normalizeTablename(s.schemaName, tableName)
var query string
vars := map[string]string{
"schema": s.schemaName,
"constraint_name": constraintName,
"constraint_type": constraintType,
"table_name": tableName,
"constraint_definition": constraintDefinition,
"norm_table_name": normTableName,
}
switch s.dbType {
case model.SqliteDBType:
// SQLite doesn't have a generic way to add constraint. For example, you can only create indexes on existing tables.
// For other constraints, you need to re-build the table. So skipping here.
// Include SQLite specific migration in original migration file.
query = fmt.Sprintf("\n-- Sqlite3 cannot drop constraints; drop constraint '%s' in table '%s' skipped\n", constraintName, tableName)
case model.MysqlDBType:
query = replaceVars(`
SET @stmt = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE constraint_schema = '[[schema]]'
AND constraint_name = '[[constraint_name]]'
AND constraint_type = '[[constraint_type]]'
AND table_name = '[[table_name]]'
) > 0,
'SELECT 1;',
'ALTER TABLE [[norm_table_name]] ADD CONSTRAINT [[constraint_name]] [[constraint_definition]];'
));
PREPARE addConstraintIfNeeded FROM @stmt;
EXECUTE addConstraintIfNeeded;
DEALLOCATE PREPARE addConstraintIfNeeded;
`, vars)
case model.PostgresDBType:
query = replaceVars(`
DO
$$
BEGIN
IF NOT EXISTS (
SELECT * FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE constraint_schema = '[[schema]]'
AND constraint_name = '[[constraint_name]]'
AND constraint_type = '[[constraint_type]]'
AND table_name = '[[table_name]]'
) THEN
ALTER TABLE [[norm_table_name]] ADD CONSTRAINT [[constraint_name]] [[constraint_definition]];
END IF;
END;
$$
LANGUAGE plpgsql;
`, vars)
}
return query, nil
}
func addPrefixIfNeeded(s, prefix string) string {
if !strings.HasPrefix(s, prefix) {
return prefix + s

View File

@ -0,0 +1 @@
DELETE FROM {{.prefix}}category_boards WHERE delete_at > 0;

View File

@ -0,0 +1,3 @@
{{ if or .postgres .mysql }}
{{ dropColumnIfNeeded "category_boards" "delete_at" }}
{{end}}

View File

@ -0,0 +1 @@
{{ addColumnIfNeeded "category_boards" "hidden" "boolean" "" }}

View File

@ -0,0 +1,26 @@
{{if or .mysql .postgres}}
{{ addConstraintIfNeeded "category_boards" "unique_user_category_board" "UNIQUE" "UNIQUE(user_id, board_id)"}}
{{end}}
{{if .sqlite}}
ALTER TABLE {{.prefix}}category_boards RENAME TO {{.prefix}}category_boards_old;
CREATE TABLE {{.prefix}}category_boards (
id varchar(36) NOT NULL,
user_id varchar(36) NOT NULL,
category_id varchar(36) NOT NULL,
board_id VARCHAR(36) NOT NULL,
create_at BIGINT,
update_at BIGINT,
sort_order BIGINT,
hidden boolean,
PRIMARY KEY (id),
CONSTRAINT unique_user_category_board UNIQUE (user_id, board_id)
);
INSERT INTO {{.prefix}}category_boards
(id, user_id, category_id, board_id, create_at, update_at, sort_order, hidden)
SELECT id, user_id, category_id, board_id, create_at, update_at, sort_order, hidden FROM {{.prefix}}category_boards_old;
DROP TABLE {{.prefix}}category_boards_old;
{{end}}

View File

@ -0,0 +1,57 @@
{{if .plugin}}
{{if .mysql}}
UPDATE {{.prefix}}category_boards AS fcb
JOIN Preferences p
ON fcb.user_id = p.userid
AND p.category = 'focalboard'
AND p.name = 'hiddenBoardIDs'
SET hidden = true
WHERE p.value LIKE concat('%', fcb.board_id, '%');
{{end}}
{{if .postgres}}
UPDATE {{.prefix}}category_boards as fcb
SET hidden = true
FROM preferences p
WHERE p.userid = fcb.user_id
AND p.category = 'focalboard'
AND p.name = 'hiddenBoardIDs'
AND p.value like ('%' || fcb.board_id || '%');
{{end}}
{{else}}
{{if .mysql}}
UPDATE {{.prefix}}category_boards AS fcb
JOIN {{.prefix}}preferences p
ON fcb.user_id = p.userid
AND p.category = 'focalboard'
AND p.name = 'hiddenBoardIDs'
SET hidden = true
WHERE p.value LIKE concat('%', fcb.board_id, '%');
{{end}}
{{if .postgres}}
UPDATE {{.prefix}}category_boards as fcb
SET hidden = true
FROM {{.prefix}}preferences p
WHERE p.userid = fcb.user_id
AND p.category = 'focalboard'
AND p.name = 'hiddenBoardIDs'
AND p.value like ('%' || fcb.board_id || '%');
{{end}}
{{end}}
{{if .sqlite}}
UPDATE {{.prefix}}category_boards
SET hidden = true
WHERE (user_id || '_' || board_id)
IN
(
SELECT (fcb.user_id || '_' || fcb.board_id)
FROM {{.prefix}}category_boards AS fcb
JOIN {{.prefix}}preferences p
ON p.userid = fcb.user_id
AND p.category = 'focalboard'
AND p.name = 'hiddenBoardIDs' WHERE
p.value LIKE ('%' || fcb.board_id || '%')
);
{{end}}

View File

@ -0,0 +1,5 @@
{{if .plugin}}
DELETE FROM Preferences WHERE category = 'focalboard' AND name = 'hiddenBoardIDs';
{{else}}
DELETE FROM {{.prefix}}preferences WHERE category = 'focalboard' AND name = 'hiddenBoardIDs';
{{end}}

View File

@ -0,0 +1,6 @@
INSERT INTO focalboard_category_boards values
('id-1', 'user_id-1', 'category-id-1', 'board-id-1', 1672988834402, 1672988834402, 0, 0),
('id-2', 'user_id-1', 'category-id-2', 'board-id-1', 1672988834402, 1672988834402, 0, 0),
('id-3', 'user_id-2', 'category-id-3', 'board-id-2', 1672988834402, 1672988834402, 1672988834402, 0),
('id-4', 'user_id-2', 'category-id-3', 'board-id-4', 1672988834402, 1672988834402, 0, 0),
('id-5', 'user_id-3', 'category-id-4', 'board-id-3', 1672988834402, 1672988834402, 1672988834402, 0);

View File

@ -0,0 +1,6 @@
INSERT INTO focalboard_category_boards values
('id-1', 'user_id-1', 'category-id-1', 'board-id-1', 1672988834402, 1672988834402, 0, 0),
('id-2', 'user_id-1', 'category-id-2', 'board-id-1', 1672988834402, 1672988834402, 0, 0),
('id-3', 'user_id-2', 'category-id-3', 'board-id-2', 1672988834402, 1672988834402, 0, 0),
('id-4', 'user_id-2', 'category-id-3', 'board-id-4', 1672988834402, 1672988834402, 0, 0),
('id-5', 'user_id-3', 'category-id-4', 'board-id-3', 1672988834402, 1672988834402, 0, 0);

View File

@ -0,0 +1 @@
ALTER TABLE focalboard_category_boards DROP COLUMN delete_at;

View File

@ -0,0 +1 @@
ALTER TABLE focalboard_category_boards ADD COLUMN hidden boolean;

View File

@ -0,0 +1,10 @@
INSERT INTO focalboard_category_boards VALUES
('id-1', 'user-id-1', 'category-id-1', 'board-id-1', 1672889246832, 1672889246832, 0, false),
('id-2', 'user-id-1', 'category-id-2', 'board-id-2', 1672889246832, 1672889246832, 0, false),
('id-3', 'user-id-2', 'category-id-3', 'board-id-3', 1672889246832, 1672889246832, 0, false),
('id-4', 'user-id-2', 'category-id-3', 'board-id-4', 1672889246832, 1672889246832, 0, false),
('id-5', 'user-id-3', 'category-id-4', 'board-id-5', 1672889246832, 1672889246832, 0, false);
INSERT INTO Preferences VALUES
('user-id-1', 'focalboard', 'hiddenBoardIDs', '["board-id-1"]'),
('user-id-2', 'focalboard', 'hiddenBoardIDs', '["board-id-3", "board-id-4"]');

View File

@ -0,0 +1,6 @@
INSERT INTO focalboard_category_boards VALUES
('id-1', 'user-id-1', 'category-id-1', 'board-id-1', 1672889246832, 1672889246832, 0, false),
('id-2', 'user-id-1', 'category-id-2', 'board-id-2', 1672889246832, 1672889246832, 0, false),
('id-3', 'user-id-2', 'category-id-3', 'board-id-3', 1672889246832, 1672889246832, 0, false),
('id-4', 'user-id-2', 'category-id-3', 'board-id-4', 1672889246832, 1672889246832, 0, false),
('id-5', 'user-id-3', 'category-id-4', 'board-id-5', 1672889246832, 1672889246832, 0, false);

View File

@ -0,0 +1,10 @@
INSERT INTO focalboard_category_boards VALUES
('id-1', 'user-id-1', 'category-id-1', 'board-id-1', 1672889246832, 1672889246832, 0, false),
('id-2', 'user-id-1', 'category-id-2', 'board-id-2', 1672889246832, 1672889246832, 0, false),
('id-3', 'user-id-2', 'category-id-3', 'board-id-3', 1672889246832, 1672889246832, 0, false),
('id-4', 'user-id-2', 'category-id-3', 'board-id-4', 1672889246832, 1672889246832, 0, false),
('id-5', 'user-id-3', 'category-id-4', 'board-id-5', 1672889246832, 1672889246832, 0, false);
INSERT INTO Preferences VALUES
('user-id-1', 'focalboard', 'hiddenBoardIDs', ''),
('user-id-2', 'focalboard', 'hiddenBoardIDs', '');

View File

@ -0,0 +1,10 @@
INSERT INTO focalboard_category_boards VALUES
('id-1', 'user-id-1', 'category-id-1', 'board-id-1', 1672889246832, 1672889246832, 0, false),
('id-2', 'user-id-1', 'category-id-2', 'board-id-2', 1672889246832, 1672889246832, 0, false),
('id-3', 'user-id-2', 'category-id-3', 'board-id-3', 1672889246832, 1672889246832, 0, false),
('id-4', 'user-id-2', 'category-id-3', 'board-id-4', 1672889246832, 1672889246832, 0, false),
('id-5', 'user-id-3', 'category-id-4', 'board-id-5', 1672889246832, 1672889246832, 0, false);
INSERT INTO focalboard_preferences VALUES
('user-id-1', 'focalboard', 'hiddenBoardIDs', '["board-id-1"]'),
('user-id-2', 'focalboard', 'hiddenBoardIDs', '["board-id-3", "board-id-4"]');

View File

@ -0,0 +1,10 @@
INSERT INTO focalboard_category_boards VALUES
('id-1', 'user-id-1', 'category-id-1', 'board-id-1', 1672889246832, 1672889246832, 0, false),
('id-2', 'user-id-1', 'category-id-2', 'board-id-2', 1672889246832, 1672889246832, 0, false),
('id-3', 'user-id-2', 'category-id-3', 'board-id-3', 1672889246832, 1672889246832, 0, false),
('id-4', 'user-id-2', 'category-id-3', 'board-id-4', 1672889246832, 1672889246832, 0, false),
('id-5', 'user-id-3', 'category-id-4', 'board-id-5', 1672889246832, 1672889246832, 0, false);
INSERT INTO focalboard_preferences VALUES
('user-id-1', 'focalboard', 'hiddenBoardIDs', ''),
('user-id-2', 'focalboard', 'hiddenBoardIDs', '');

View File

@ -0,0 +1,5 @@
INSERT INTO Preferences VALUES
('user-id-1', 'focalboard', 'hiddenBoardIDs', '["board-id-1"]'),
('user-id-2', 'focalboard', 'hiddenBoardIDs', '["board-id-3", "board-id-4"]'),
('user-id-3', 'lorem', 'lorem', ''),
('user-id-4', 'ipsum', 'ipsum', '');

View File

@ -0,0 +1,5 @@
INSERT INTO focalboard_preferences VALUES
('user-id-1', 'focalboard', 'hiddenBoardIDs', '["board-id-1"]'),
('user-id-2', 'focalboard', 'hiddenBoardIDs', '["board-id-3", "board-id-4"]'),
('user-id-2', 'lorem', 'lorem', ''),
('user-id-2', 'ipsum', 'ipsum', '');

View File

@ -15,6 +15,18 @@ type TestHelper struct {
isPlugin bool
}
func (th *TestHelper) IsPostgres() bool {
return th.f.DB().DriverName() == "postgres"
}
func (th *TestHelper) IsMySQL() bool {
return th.f.DB().DriverName() == "mysql"
}
func (th *TestHelper) IsSQLite() bool {
return th.f.DB().DriverName() == "sqlite3"
}
func SetupPluginTestHelper(t *testing.T) (*TestHelper, func()) {
dbType := strings.TrimSpace(os.Getenv("FOCALBOARD_STORE_TEST_DB_TYPE"))
if dbType == "" || dbType == model.SqliteDBType {

View File

@ -0,0 +1,57 @@
package migrationstests
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test34DropDeleteAtColumnMySQLPostgres(t *testing.T) {
t.Run("column exists", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.f.MigrateToStep(34)
// migration 34 only works for MySQL and PostgreSQL
if th.IsMySQL() {
var count int
query := "SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'focalboard_category_boards' AND column_name = 'delete_at'"
th.f.DB().Get(&count, query)
require.Equal(t, 0, count)
} else if th.IsPostgres() {
var count int
query := "select count(*) from information_schema.columns where table_name = 'focalboard_category_boards' and column_name = 'delete_at'"
th.f.DB().Get(&count, query)
require.Equal(t, 0, count)
}
})
t.Run("column already deleted", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
// For migration 34, we don't drop column
// on SQLite, so no need to test for it.
if th.IsSQLite() {
return
}
th.f.MigrateToStep(33).
ExecFile("./fixtures/test34_drop_delete_at_column.sql")
th.f.MigrateToStep(34)
if th.IsMySQL() {
var count int
query := "SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name = 'focalboard_category_boards' AND column_name = 'delete_at'"
th.f.DB().Get(&count, query)
require.Equal(t, 0, count)
} else if th.IsPostgres() {
var count int
query := "select count(*) from information_schema.columns where table_name = 'focalboard_category_boards' and column_name = 'delete_at'"
th.f.DB().Get(&count, query)
require.Equal(t, 0, count)
}
})
}

View File

@ -0,0 +1,27 @@
package migrationstests
import "testing"
func Test35AddHIddenColumnToCategoryBoards(t *testing.T) {
t.Run("base case - column doesn't already exist", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.f.MigrateToStep(35)
})
t.Run("column already exist", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
// We don't support adding column in idempotent manner
// for SQLite, so no need to check for it.
if th.IsSQLite() {
return
}
th.f.MigrateToStep(34).
ExecFile("./fixtures/test35_add_hidden_column.sql")
th.f.MigrateToStep(35)
})
}

View File

@ -0,0 +1,69 @@
package migrationstests
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test36AddUniqueConstraintToCategoryBoards(t *testing.T) {
t.Run("constraint doesn't alreadt exists", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.f.MigrateToStep(36)
// verifying if constraint has been added
//can't verify in sqlite, so skipping it
if th.IsSQLite() {
return
}
var count int
query := "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " +
"WHERE constraint_name = 'unique_user_category_board' " +
"AND constraint_type = 'UNIQUE' " +
"AND table_name = 'focalboard_category_boards'"
th.f.DB().Get(&count, query)
require.Equal(t, 1, count)
})
t.Run("constraint already exists", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
// SQLIte doesn't support adding constraint to existing table
// and neither do we, so skipping for sqlite
if th.IsSQLite() {
return
}
th.f.MigrateToStep(35)
if th.IsMySQL() {
th.f.DB().Exec("alter table focalboard_category_boards add constraint unique_user_category_board UNIQUE(user_id, board_id);")
} else if th.IsPostgres() {
th.f.DB().Exec("ALTER TABLE focalboard_category_boards ADD CONSTRAINT unique_user_category_board UNIQUE(user_id, board_id);")
}
th.f.MigrateToStep(36)
var schema string
if th.IsMySQL() {
schema = "DATABASE()"
} else if th.IsPostgres() {
schema = "'public'"
}
var count int
query := "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " +
"WHERE constraint_schema = " + schema + " " +
"AND constraint_name = 'unique_user_category_board' " +
"AND constraint_type = 'UNIQUE' " +
"AND table_name = 'focalboard_category_boards'"
th.f.DB().Get(&count, query)
require.Equal(t, 1, count)
})
}

View File

@ -0,0 +1,134 @@
package migrationstests
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test37MigrateHiddenBoardIDTest(t *testing.T) {
t.Run("no existing hidden boards exist", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.f.MigrateToStep(37)
})
t.Run("SQLite - existsing category boards with some hidden boards", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
if th.IsMySQL() || th.IsPostgres() {
return
}
th.f.MigrateToStep(36).
ExecFile("./fixtures/test37_valid_data_sqlite.sql")
th.f.MigrateToStep(37)
type categoryBoard struct {
User_ID string
Category_ID string
Board_ID string
Hidden bool
}
var hiddenCategoryBoards []categoryBoard
query := "SELECT user_id, category_id, board_id, hidden FROM focalboard_category_boards WHERE hidden = true"
err := th.f.DB().Select(&hiddenCategoryBoards, query)
require.NoError(t, err)
require.Equal(t, 3, len(hiddenCategoryBoards))
require.Contains(t, hiddenCategoryBoards, categoryBoard{User_ID: "user-id-1", Category_ID: "category-id-1", Board_ID: "board-id-1", Hidden: true})
require.Contains(t, hiddenCategoryBoards, categoryBoard{User_ID: "user-id-2", Category_ID: "category-id-3", Board_ID: "board-id-3", Hidden: true})
require.Contains(t, hiddenCategoryBoards, categoryBoard{User_ID: "user-id-2", Category_ID: "category-id-3", Board_ID: "board-id-4", Hidden: true})
})
t.Run("MySQL and PostgreSQL - existsing category boards with some hidden boards", func(t *testing.T) {
th, tearDown := SetupPluginTestHelper(t)
defer tearDown()
if th.IsSQLite() {
return
}
th.f.MigrateToStep(36).
ExecFile("./fixtures/test37_valid_data.sql")
th.f.MigrateToStep(37)
type categoryBoard struct {
User_ID string
Category_ID string
Board_ID string
Hidden bool
}
var hiddenCategoryBoards []categoryBoard
query := "SELECT user_id, category_id, board_id, hidden FROM focalboard_category_boards WHERE hidden = true"
err := th.f.DB().Select(&hiddenCategoryBoards, query)
require.NoError(t, err)
require.Equal(t, 3, len(hiddenCategoryBoards))
require.Contains(t, hiddenCategoryBoards, categoryBoard{User_ID: "user-id-1", Category_ID: "category-id-1", Board_ID: "board-id-1", Hidden: true})
require.Contains(t, hiddenCategoryBoards, categoryBoard{User_ID: "user-id-2", Category_ID: "category-id-3", Board_ID: "board-id-3", Hidden: true})
require.Contains(t, hiddenCategoryBoards, categoryBoard{User_ID: "user-id-2", Category_ID: "category-id-3", Board_ID: "board-id-4", Hidden: true})
})
t.Run("no hidden boards", func(t *testing.T) {
th, tearDown := SetupPluginTestHelper(t)
defer tearDown()
th.f.MigrateToStep(36).
ExecFile("./fixtures/test37_valid_data_no_hidden_boards.sql")
th.f.MigrateToStep(37)
var count int
query := "SELECT count(*) FROM focalboard_category_boards WHERE hidden = true"
err := th.f.DB().Get(&count, query)
require.NoError(t, err)
require.Equal(t, 0, count)
})
t.Run("SQLite - preference but no hidden board", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
if th.IsMySQL() || th.IsPostgres() {
return
}
th.f.MigrateToStep(36).
ExecFile("./fixtures/test37_valid_data_sqlite_preference_but_no_hidden_board.sql")
th.f.MigrateToStep(37)
var count int
query := "SELECT count(*) FROM focalboard_category_boards WHERE hidden = true"
err := th.f.DB().Get(&count, query)
require.NoError(t, err)
require.Equal(t, 0, count)
})
t.Run("MySQL and PostgreSQL - preference but no hidden board", func(t *testing.T) {
th, tearDown := SetupPluginTestHelper(t)
defer tearDown()
if th.IsSQLite() {
return
}
th.f.MigrateToStep(36).
ExecFile("./fixtures/test37_valid_data_preference_but_no_hidden_board.sql")
th.f.MigrateToStep(37)
var count int
query := "SELECT count(*) FROM focalboard_category_boards WHERE hidden = true"
err := th.f.DB().Get(&count, query)
require.NoError(t, err)
require.Equal(t, 0, count)
})
}

View File

@ -0,0 +1,63 @@
package migrationstests
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test38RemoveHiddenBoardIDsFromPreferences(t *testing.T) {
t.Run("standalone - no data exist", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.f.MigrateToStep(38)
})
t.Run("plugin - no data exist", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.f.MigrateToStep(38)
})
t.Run("standalone - some data exist", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.f.MigrateToStep(37).
ExecFile("./fixtures/test38_add_standalone_preferences.sql")
// verify existing data count
var count int
countQuery := "SELECT COUNT(*) FROM focalboard_preferences"
err := th.f.DB().Get(&count, countQuery)
require.NoError(t, err)
require.Equal(t, 4, count)
th.f.MigrateToStep(38)
// now the count should be 0
err = th.f.DB().Get(&count, countQuery)
require.NoError(t, err)
require.Equal(t, 2, count)
})
t.Run("plugin - some data exist", func(t *testing.T) {
th, tearDown := SetupPluginTestHelper(t)
defer tearDown()
th.f.MigrateToStep(37).
ExecFile("./fixtures/test38_add_plugin_preferences.sql")
// verify existing data count
var count int
countQuery := "SELECT COUNT(*) FROM Preferences"
err := th.f.DB().Get(&count, countQuery)
require.NoError(t, err)
require.Equal(t, 4, count)
th.f.MigrateToStep(38)
// now the count should be 0
err = th.f.DB().Get(&count, countQuery)
require.NoError(t, err)
require.Equal(t, 2, count)
})
}

View File

@ -0,0 +1,63 @@
package migrationstests
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test33RemoveDeletedCategoryBoards(t *testing.T) {
t.Run("base case - no data in table", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.f.MigrateToStep(33)
})
t.Run("existing data - 2 soft deleted records", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.f.MigrateToStep(32).
ExecFile("./fixtures/test33_with_deleted_data.sql")
// cound total records
var count int
err := th.f.DB().Get(&count, "SELECT COUNT(*) FROM focalboard_category_boards")
require.NoError(t, err)
require.Equal(t, 5, count)
// now we run the migration
th.f.MigrateToStep(33)
// and verify record count again.
// The soft deleted records should have been removed from the DB now
err = th.f.DB().Get(&count, "SELECT COUNT(*) FROM focalboard_category_boards")
require.NoError(t, err)
require.Equal(t, 3, count)
})
t.Run("existing data - no soft deleted records", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
th.f.MigrateToStep(32).
ExecFile("./fixtures/test33_with_no_deleted_data.sql")
// cound total records
var count int
err := th.f.DB().Get(&count, "SELECT COUNT(*) FROM focalboard_category_boards")
require.NoError(t, err)
require.Equal(t, 5, count)
// now we run the migration
th.f.MigrateToStep(33)
// and verify record count again.
// Since there were no soft-deleted records, nothing should have been
// deleted from the database.
err = th.f.DB().Get(&count, "SELECT COUNT(*) FROM focalboard_category_boards")
require.NoError(t, err)
require.Equal(t, 5, count)
})
}

View File

@ -22,15 +22,15 @@ import (
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
func (s *SQLStore) AddUpdateCategoryBoard(userID string, boardCategoryMapping map[string]string) error {
func (s *SQLStore) AddUpdateCategoryBoard(userID string, categoryID string, boardIDs []string) error {
if s.dbType == model.SqliteDBType {
return s.addUpdateCategoryBoard(s.db, userID, boardCategoryMapping)
return s.addUpdateCategoryBoard(s.db, userID, categoryID, boardIDs)
}
tx, txErr := s.db.BeginTx(context.Background(), nil)
if txErr != nil {
return txErr
}
err := s.addUpdateCategoryBoard(tx, userID, boardCategoryMapping)
err := s.addUpdateCategoryBoard(tx, userID, categoryID, boardIDs)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "AddUpdateCategoryBoard"))
@ -850,6 +850,11 @@ func (s *SQLStore) SendMessage(message string, postType string, receipts []strin
}
func (s *SQLStore) SetBoardVisibility(userID string, categoryID string, boardID string, visible bool) error {
return s.setBoardVisibility(s.db, userID, categoryID, boardID, visible)
}
func (s *SQLStore) SetSystemSetting(key string, value string) error {
return s.setSystemSetting(s.db, key, value)

View File

@ -131,8 +131,9 @@ type Store interface {
SaveFileInfo(fileInfo *mmModel.FileInfo) error
// @withTransaction
AddUpdateCategoryBoard(userID string, boardCategoryMapping map[string]string) error
AddUpdateCategoryBoard(userID, categoryID string, boardIDs []string) error
ReorderCategoryBoards(categoryID string, newBoardsOrder []string) ([]string, error)
SetBoardVisibility(userID, categoryID, boardID string, visible bool) error
CreateSubscription(sub *model.Subscription) (*model.Subscription, error)
DeleteSubscription(blockID string, subscriberID string) error

View File

@ -294,11 +294,11 @@ func testReorderCategoryBoards(t *testing.T, store store.Store) {
})
assert.NoError(t, err)
err = store.AddUpdateCategoryBoard("user_id", map[string]string{
"board_id_1": "category_id_1",
"board_id_2": "category_id_1",
"board_id_3": "category_id_1",
"board_id_4": "category_id_1",
err = store.AddUpdateCategoryBoard("user_id", "category_id_1", []string{
"board_id_1",
"board_id_2",
"board_id_3",
"board_id_4",
})
assert.NoError(t, err)
@ -306,11 +306,11 @@ func testReorderCategoryBoards(t *testing.T, store store.Store) {
categoryBoards, err := store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, 4, len(categoryBoards[0].BoardIDs))
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_1")
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_2")
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_3")
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_4")
assert.Equal(t, 4, len(categoryBoards[0].BoardMetadata))
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_1", Hidden: false})
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_2", Hidden: false})
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_3", Hidden: false})
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_4", Hidden: false})
// reordering
newOrder, err := store.ReorderCategoryBoards("category_id_1", []string{
@ -329,9 +329,9 @@ func testReorderCategoryBoards(t *testing.T, store store.Store) {
categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, 4, len(categoryBoards[0].BoardIDs))
assert.Equal(t, "board_id_3", categoryBoards[0].BoardIDs[0])
assert.Equal(t, "board_id_1", categoryBoards[0].BoardIDs[1])
assert.Equal(t, "board_id_2", categoryBoards[0].BoardIDs[2])
assert.Equal(t, "board_id_4", categoryBoards[0].BoardIDs[3])
assert.Equal(t, 4, len(categoryBoards[0].BoardMetadata))
assert.Equal(t, "board_id_3", categoryBoards[0].BoardMetadata[0].BoardID)
assert.Equal(t, "board_id_1", categoryBoards[0].BoardMetadata[1].BoardID)
assert.Equal(t, "board_id_2", categoryBoards[0].BoardMetadata[2].BoardID)
assert.Equal(t, "board_id_4", categoryBoards[0].BoardMetadata[3].BoardID)
}

View File

@ -15,6 +15,18 @@ func StoreTestCategoryBoardsStore(t *testing.T, setup func(t *testing.T) (store.
defer tearDown()
testGetUserCategoryBoards(t, store)
})
t.Run("AddUpdateCategoryBoard", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testAddUpdateCategoryBoard(t, store)
})
t.Run("SetBoardVisibility", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testSetBoardVisibility(t, store)
})
}
func testGetUserCategoryBoards(t *testing.T, store store.Store) {
@ -60,14 +72,14 @@ func testGetUserCategoryBoards(t *testing.T, store store.Store) {
// Adding Board 1 and Board 2 to Category 1
// The boards don't need to exists in DB for this test
err = store.AddUpdateCategoryBoard("user_id_1", map[string]string{"board_1": "category_id_1"})
err = store.AddUpdateCategoryBoard("user_id_1", "category_id_1", []string{"board_1"})
assert.NoError(t, err)
err = store.AddUpdateCategoryBoard("user_id_1", map[string]string{"board_2": "category_id_1"})
err = store.AddUpdateCategoryBoard("user_id_1", "category_id_1", []string{"board_2"})
assert.NoError(t, err)
// Adding Board 3 to Category 2
err = store.AddUpdateCategoryBoard("user_id_1", map[string]string{"board_3": "category_id_2"})
err = store.AddUpdateCategoryBoard("user_id_1", "category_id_2", []string{"board_3"})
assert.NoError(t, err)
// we'll leave category 3 empty
@ -94,13 +106,13 @@ func testGetUserCategoryBoards(t *testing.T, store store.Store) {
}
assert.NotEmpty(t, category1BoardCategory)
assert.Equal(t, 2, len(category1BoardCategory.BoardIDs))
assert.Equal(t, 2, len(category1BoardCategory.BoardMetadata))
assert.NotEmpty(t, category1BoardCategory)
assert.Equal(t, 1, len(category2BoardCategory.BoardIDs))
assert.Equal(t, 1, len(category2BoardCategory.BoardMetadata))
assert.NotEmpty(t, category1BoardCategory)
assert.Equal(t, 0, len(category3BoardCategory.BoardIDs))
assert.Equal(t, 0, len(category3BoardCategory.BoardMetadata))
t.Run("get empty category boards", func(t *testing.T) {
userCategoryBoards, err := store.GetUserCategoryBoards("nonexistent-user-id", "nonexistent-team-id")
@ -108,3 +120,142 @@ func testGetUserCategoryBoards(t *testing.T, store store.Store) {
assert.Empty(t, userCategoryBoards)
})
}
func testAddUpdateCategoryBoard(t *testing.T, store store.Store) {
// creating few boards and categories to later associoate with the category
_, _, err := store.CreateBoardsAndBlocksWithAdmin(&model.BoardsAndBlocks{
Boards: []*model.Board{
{
ID: "board_id_1",
TeamID: "team_id",
},
{
ID: "board_id_2",
TeamID: "team_id",
},
},
}, "user_id")
assert.NoError(t, err)
err = store.CreateCategory(model.Category{
ID: "category_id",
Name: "Category",
UserID: "user_id",
TeamID: "team_id",
})
assert.NoError(t, err)
// adding a few boards to the category
err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_1", "board_id_2"})
assert.NoError(t, err)
// verify inserted data
categoryBoards, err := store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "category_id", categoryBoards[0].ID)
assert.Equal(t, 2, len(categoryBoards[0].BoardMetadata))
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_1", Hidden: false})
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_2", Hidden: false})
// adding new boards to the same category
err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_3"})
assert.NoError(t, err)
// verify inserted data
categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "category_id", categoryBoards[0].ID)
assert.Equal(t, 3, len(categoryBoards[0].BoardMetadata))
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_3", Hidden: false})
// passing empty array
err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{})
assert.NoError(t, err)
// verify inserted data
categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "category_id", categoryBoards[0].ID)
assert.Equal(t, 3, len(categoryBoards[0].BoardMetadata))
// passing duplicate data in input
err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_4", "board_id_4"})
assert.NoError(t, err)
// verify inserted data
categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "category_id", categoryBoards[0].ID)
assert.Equal(t, 4, len(categoryBoards[0].BoardMetadata))
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_4", Hidden: false})
// adding already added board
err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_1", "board_id_2"})
assert.NoError(t, err)
// verify inserted data
categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "category_id", categoryBoards[0].ID)
assert.Equal(t, 4, len(categoryBoards[0].BoardMetadata))
// passing already added board along with a new board
err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_1", "board_id_5"})
assert.NoError(t, err)
// verify inserted data
categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "category_id", categoryBoards[0].ID)
assert.Equal(t, 5, len(categoryBoards[0].BoardMetadata))
assert.Contains(t, categoryBoards[0].BoardMetadata, model.CategoryBoardMetadata{BoardID: "board_id_5", Hidden: false})
}
func testSetBoardVisibility(t *testing.T, store store.Store) {
_, _, err := store.CreateBoardsAndBlocksWithAdmin(&model.BoardsAndBlocks{
Boards: []*model.Board{
{
ID: "board_id_1",
TeamID: "team_id",
},
},
}, "user_id")
assert.NoError(t, err)
err = store.CreateCategory(model.Category{
ID: "category_id",
Name: "Category",
UserID: "user_id",
TeamID: "team_id",
})
assert.NoError(t, err)
// adding a few boards to the category
err = store.AddUpdateCategoryBoard("user_id", "category_id", []string{"board_id_1"})
assert.NoError(t, err)
err = store.SetBoardVisibility("user_id", "category_id", "board_id_1", true)
assert.NoError(t, err)
// verify set visibility
categoryBoards, err := store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "category_id", categoryBoards[0].ID)
assert.Equal(t, 1, len(categoryBoards[0].BoardMetadata))
assert.False(t, categoryBoards[0].BoardMetadata[0].Hidden)
err = store.SetBoardVisibility("user_id", "category_id", "board_id_1", false)
assert.NoError(t, err)
// verify set visibility
categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.True(t, categoryBoards[0].BoardMetadata[0].Hidden)
}

View File

@ -92,7 +92,7 @@ func LoadData(t *testing.T, store store.Store) {
err = store.UpsertSharing(sharing)
require.NoError(t, err)
err = store.AddUpdateCategoryBoard(testUserID, map[string]string{boardID: categoryID})
err = store.AddUpdateCategoryBoard(testUserID, categoryID, []string{boardID})
require.NoError(t, err)
}

View File

@ -102,3 +102,20 @@ func IsCloudLicense(license *mmModel.License) bool {
license.Features.Cloud != nil &&
*license.Features.Cloud
}
func DedupeStringArr(arr []string) []string {
hashMap := map[string]bool{}
for _, item := range arr {
hashMap[item] = true
}
dedupedArr := make([]string, len(hashMap))
i := 0
for key := range hashMap {
dedupedArr[i] = key
i++
}
return dedupedArr
}

View File

@ -120,7 +120,7 @@ const me: IUser = {
const categoryAttribute1 = TestBlockFactory.createCategoryBoards()
categoryAttribute1.name = 'Category 1'
categoryAttribute1.boardIDs = [board.id]
categoryAttribute1.boardMetadata = [{boardID: board.id, hidden: false}]
describe('src/components/shareBoard/shareBoard', () => {
const w = (window as any)

View File

@ -41,11 +41,12 @@ describe('components/sidebarSidebar', () => {
const categoryAttribute1 = TestBlockFactory.createCategoryBoards()
categoryAttribute1.id = 'category1'
categoryAttribute1.name = 'Category 1'
categoryAttribute1.boardIDs = [board.id]
categoryAttribute1.boardMetadata = [{boardID: board.id, hidden: false}]
const defaultCategory = TestBlockFactory.createCategoryBoards()
defaultCategory.id = 'default_category'
defaultCategory.name = 'Boards'
defaultCategory.boardMetadata = []
test('sidebar hidden', () => {
const store = mockStore({
@ -80,6 +81,7 @@ describe('components/sidebarSidebar', () => {
categoryAttributes: [
categoryAttribute1,
],
hiddenBoardIDs: [],
},
})
@ -111,6 +113,11 @@ describe('components/sidebarSidebar', () => {
customGlobal.innerWidth = 500
const localCategoryAttribute = TestBlockFactory.createCategoryBoards()
localCategoryAttribute.id = 'category1'
localCategoryAttribute.name = 'Category 1'
categoryAttribute1.boardMetadata = [{boardID: board.id, hidden: false}]
const store = mockStore({
teams: {
current: {id: 'team-id'},
@ -143,6 +150,7 @@ describe('components/sidebarSidebar', () => {
categoryAttributes: [
categoryAttribute1,
],
hiddenBoardIDs: [],
},
})
@ -169,6 +177,11 @@ describe('components/sidebarSidebar', () => {
})
test('dont show hidden boards', () => {
const localCategoryAttribute = TestBlockFactory.createCategoryBoards()
localCategoryAttribute.id = 'category1'
localCategoryAttribute.name = 'Category 1'
localCategoryAttribute.boardMetadata = [{boardID: board.id, hidden: true}]
const store = mockStore({
teams: {
current: {id: 'team-id'},
@ -203,8 +216,9 @@ describe('components/sidebarSidebar', () => {
},
sidebar: {
categoryAttributes: [
categoryAttribute1,
localCategoryAttribute,
],
hiddenBoardIDs: [board.id],
},
})
@ -236,6 +250,7 @@ describe('components/sidebarSidebar', () => {
collapsedCategory.id = 'categoryCollapsed'
collapsedCategory.name = 'Category 2'
collapsedCategory.collapsed = true
collapsedCategory.boardMetadata = []
const store = mockStore({
teams: {
@ -270,6 +285,7 @@ describe('components/sidebarSidebar', () => {
categoryAttribute1,
collapsedCategory,
],
hiddenBoardIDs: [],
},
})
@ -327,6 +343,7 @@ describe('components/sidebarSidebar', () => {
categoryAttribute1,
defaultCategory,
],
hiddenBoardIDs: [],
},
})
@ -355,7 +372,7 @@ describe('components/sidebarSidebar', () => {
const categoryAttribute2 = TestBlockFactory.createCategoryBoards()
categoryAttribute2.id = 'category2'
categoryAttribute2.name = 'Category 2'
categoryAttribute2.boardIDs = [board2.id]
categoryAttribute2.boardMetadata = [{boardID: board2.id, hidden: false}]
const store = mockStore({
teams: {

View File

@ -124,8 +124,8 @@ const Sidebar = (props: Props) => {
// and thats the first time that user is opening that board.
// Here we check if that board has a associated category for the user. If not,
// we assign it to the default "Boards" category.
// We do this on the client side rather than the server side live for all other cases
// is because there is no good, explicit API call to add this logic to when opening
// We do this on the client side rather than the server side like for all other cases
// because there is no good, explicit API call to add this logic to when opening
// a board that you have implicit access to.
useEffect(() => {
if (!sidebarCategories || sidebarCategories.length === 0 || !currentBoard || !team || currentBoard.isTemplate) {
@ -133,7 +133,8 @@ const Sidebar = (props: Props) => {
}
// find the category the current board belongs to
const category = sidebarCategories.find((c) => c.boardIDs.indexOf(currentBoard.id) >= 0)
// const category = sidebarCategories.find((c) => c.boardIDs.indexOf(currentBoard.id) >= 0)
const category = sidebarCategories.find((c) => c.boardMetadata.find((boardMetadata) => boardMetadata.boardID === currentBoard.id))
if (category) {
// Boards does belong to a category.
// All good here. Nothing to do
@ -219,29 +220,41 @@ const Sidebar = (props: Props) => {
return
}
const boardIDs = [...toSidebarCategory.boardIDs]
boardIDs.splice(source.index, 1)
boardIDs.splice(destination.index, 0, toSidebarCategory.boardIDs[source.index])
const categoryBoardMetadata = [...toSidebarCategory.boardMetadata]
categoryBoardMetadata.splice(source.index, 1)
categoryBoardMetadata.splice(destination.index, 0, toSidebarCategory.boardMetadata[source.index])
dispatch(updateCategoryBoardsOrder({categoryID: toCategoryID, boardIDs}))
await octoClient.reorderSidebarCategoryBoards(team.id, toCategoryID, boardIDs)
dispatch(updateCategoryBoardsOrder({categoryID: toCategoryID, boardsMetadata: categoryBoardMetadata}))
const reorderedBoardIDs = categoryBoardMetadata.map((m) => m.boardID)
await octoClient.reorderSidebarCategoryBoards(team.id, toCategoryID, reorderedBoardIDs)
} else {
// board moved to a different category
const toSidebarCategory = sidebarCategories.find((category) => category.id === toCategoryID)
const fromSidebarCategory = sidebarCategories.find((category) => category.id === fromCategoryID)
if (!toSidebarCategory) {
Utils.logError(`toCategoryID not found in list of sidebar categories. toCategoryID: ${toCategoryID}`)
return
}
const boardIDs = [...toSidebarCategory.boardIDs]
boardIDs.splice(destination.index, 0, boardID)
if (!fromSidebarCategory) {
Utils.logError(`fromCategoryID not found in list of sidebar categories. fromCategoryID: ${fromCategoryID}`)
return
}
const categoryBoardMetadata = [...toSidebarCategory.boardMetadata]
const fromCategoryBoardMetadata = fromSidebarCategory.boardMetadata[source.index]
categoryBoardMetadata.splice(destination.index, 0, fromCategoryBoardMetadata)
// optimistically updating the store to create a lag-free UI.
await dispatch(updateCategoryBoardsOrder({categoryID: toCategoryID, boardIDs}))
dispatch(updateBoardCategories([{boardID, categoryID: toCategoryID}]))
await dispatch(updateCategoryBoardsOrder({categoryID: toCategoryID, boardsMetadata: categoryBoardMetadata}))
dispatch(updateBoardCategories([{...fromCategoryBoardMetadata, categoryID: toCategoryID}]))
await mutator.moveBoardToCategory(team.id, boardID, toCategoryID, fromCategoryID)
await octoClient.reorderSidebarCategoryBoards(team.id, toCategoryID, boardIDs)
const reorderedBoardIDs = categoryBoardMetadata.map((m) => m.boardID)
await octoClient.reorderSidebarCategoryBoards(team.id, toCategoryID, reorderedBoardIDs)
}
}, [team, sidebarCategories])
@ -313,7 +326,7 @@ const Sidebar = (props: Props) => {
const getSortedCategoryBoards = (category: CategoryBoards): Board[] => {
const categoryBoardsByID = new Map<string, Board>()
boards.forEach((board) => {
if (!category.boardIDs.includes(board.id)) {
if (!category.boardMetadata.find((m) => m.boardID === board.id)) {
return
}
@ -321,8 +334,8 @@ const Sidebar = (props: Props) => {
})
const sortedBoards: Board[] = []
category.boardIDs.forEach((boardID) => {
const b = categoryBoardsByID.get(boardID)
category.boardMetadata.forEach((boardMetadata) => {
const b = categoryBoardsByID.get(boardMetadata.boardID)
if (b) {
sortedBoards.push(b)
}

View File

@ -28,7 +28,7 @@ describe('components/sidebarBoardItem', () => {
const categoryBoards1 = TestBlockFactory.createCategoryBoards()
categoryBoards1.name = 'Category 1'
categoryBoards1.boardIDs = [board.id]
categoryBoards1.boardMetadata = [{boardID: board.id, hidden: false}]
const categoryBoards2 = TestBlockFactory.createCategoryBoards()
categoryBoards2.name = 'Category 2'

View File

@ -35,10 +35,9 @@ import {Utils} from '../../utils'
import AddIcon from '../../widgets/icons/add'
import CloseIcon from '../../widgets/icons/close'
import {UserConfigPatch} from '../../user'
import {getMe, getMyConfig, patchProps} from '../../store/users'
import {getMe} from '../../store/users'
import octoClient from '../../octoClient'
import {getCurrentBoardId, getMySortedBoards} from '../../store/boards'
import {getCurrentBoardId} from '../../store/boards'
import {UserSettings} from '../../userSettings'
import {Archiver} from '../../archiver'
@ -75,12 +74,10 @@ const SidebarBoardItem = (props: Props) => {
const currentViewId = useAppSelector(getCurrentViewId)
const teamID = team?.id || ''
const me = useAppSelector(getMe)
const myConfig = useAppSelector(getMyConfig)
const match = useRouteMatch<{boardId: string, viewId?: string, cardId?: string, teamId?: string}>()
const history = useHistory()
const dispatch = useAppDispatch()
const myAllBoards = useAppSelector(getMySortedBoards)
const currentBoardID = useAppSelector(getCurrentBoardId)
const generateMoveToCategoryOptions = (boardID: string) => {
@ -136,6 +133,7 @@ const SidebarBoardItem = (props: Props) => {
await dispatch(updateBoardCategories([{
boardID: boardId,
categoryID: props.categoryBoards.id,
hidden: false,
}]))
}
@ -155,23 +153,14 @@ const SidebarBoardItem = (props: Props) => {
return
}
// creating new array as myConfig.hiddenBoardIDs.value
// belongs to Redux state and so is immutable.
const hiddenBoards = {...(myConfig.hiddenBoardIDs ? myConfig.hiddenBoardIDs.value : {})}
hiddenBoards[board.id] = true
const hiddenBoardsArray = Object.keys(hiddenBoards)
const patch: UserConfigPatch = {
updatedFields: {
hiddenBoardIDs: JSON.stringify(hiddenBoardsArray),
await octoClient.hideBoard(props.categoryBoards.id, board.id)
dispatch(updateBoardCategories([
{
boardID: board.id,
categoryID: props.categoryBoards.id,
hidden: true,
},
}
const patchedProps = await octoClient.patchUserConfig(me.id, patch)
if (!patchedProps) {
return
}
await dispatch(patchProps(patchedProps))
]))
// If we're hiding the board we're currently on,
// we need to switch to a different board once its hidden.
@ -181,17 +170,22 @@ const SidebarBoardItem = (props: Props) => {
// Empty board ID navigates to template picker, which is
// fine if there are no more visible boards to switch to.
const visibleBoards = myAllBoards.filter((b) => !hiddenBoards[b.id])
if (visibleBoards.length === 0) {
// find the first visible board
let visibleBoardID: string | null = null
for (const iterCategory of props.allCategories) {
const visibleBoardMetadata = iterCategory.boardMetadata.find((categoryBoardMetadata) => !categoryBoardMetadata.hidden && categoryBoardMetadata.boardID !== props.board.id)
if (visibleBoardMetadata) {
visibleBoardID = visibleBoardMetadata.boardID
break
}
}
if (visibleBoardID === null) {
UserSettings.setLastBoardID(match.params.teamId!, null)
showTemplatePicker()
} else {
let nextBoardID = ''
if (visibleBoards.length > 0) {
nextBoardID = visibleBoards[0].id
}
props.showBoard(nextBoardID)
props.showBoard(visibleBoardID)
}
}
}

View File

@ -36,7 +36,7 @@ describe('components/sidebarCategory', () => {
const categoryBoards1 = TestBlockFactory.createCategoryBoards()
categoryBoards1.id = 'category_1_id'
categoryBoards1.name = 'Category 1'
categoryBoards1.boardIDs = [board1.id, board2.id]
categoryBoards1.boardMetadata = [{boardID: board1.id, hidden: false}, {boardID: board2.id, hidden: false}]
const categoryBoards2 = TestBlockFactory.createCategoryBoards()
categoryBoards2.id = 'category_2_id'

View File

@ -20,14 +20,13 @@ import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import './sidebarCategory.scss'
import {Category, CategoryBoards} from '../../store/sidebar'
import {Category, CategoryBoardMetadata, CategoryBoards} from '../../store/sidebar'
import ChevronDown from '../../widgets/icons/chevronDown'
import ChevronRight from '../../widgets/icons/chevronRight'
import CreateNewFolder from '../../widgets/icons/newFolder'
import CreateCategory from '../createCategory/createCategory'
import {useAppSelector} from '../../store/hooks'
import {
getMyConfig,
getOnboardingTourCategory,
getOnboardingTourStep,
} from '../../store/users'
@ -76,7 +75,6 @@ const SidebarCategory = (props: Props) => {
const match = useRouteMatch<{boardId: string, viewId?: string, cardId?: string, teamId?: string}>()
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false)
const [showUpdateCategoryModal, setShowUpdateCategoryModal] = useState(false)
const myConfig = useAppSelector(getMyConfig)
const onboardingTourCategory = useAppSelector(getOnboardingTourCategory)
const onboardingTourStep = useAppSelector(getOnboardingTourStep)
@ -129,19 +127,20 @@ const SidebarCategory = (props: Props) => {
props.hideSidebar()
}, [match, history])
const isBoardVisible = (boardID: string): boolean => {
const isBoardVisible = (boardID: string, existingBoardMetadata?: CategoryBoardMetadata): boolean => {
const categoryBoardMetadata = existingBoardMetadata || sidebarBoardMetadata.find((metadata) => metadata.boardID === boardID)
// hide if board doesn't belong to current category
if (!blocks.includes(boardID)) {
if (!categoryBoardMetadata) {
return false
}
// hide if board was hidden by the user
const hiddenBoardIDs = myConfig.hiddenBoardIDs?.value || {}
return !hiddenBoardIDs[boardID]
return !categoryBoardMetadata.hidden
}
const blocks = props.categoryBoards.boardIDs || []
const visibleBlocks = props.categoryBoards.boardIDs.filter((boardID) => isBoardVisible(boardID))
const sidebarBoardMetadata = props.categoryBoards.boardMetadata || []
const visibleBlocks = props.categoryBoards.boardMetadata.filter((boardMetadata) => isBoardVisible(boardMetadata.boardID, boardMetadata))
const handleCreateNewCategory = () => {
setShowCreateCategoryModal(true)

View File

@ -92,7 +92,7 @@ const me: IUser = {
const categoryAttribute1 = TestBlockFactory.createCategoryBoards()
categoryAttribute1.name = 'Category 1'
categoryAttribute1.boardIDs = [board.id]
categoryAttribute1.boardMetadata = [{boardID: board.id, hidden: false}]
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom')
@ -171,6 +171,7 @@ describe('src/components/workspace', () => {
categoryAttributes: [
categoryAttribute1,
],
hiddenBoardIDs: [],
},
}
mockedOctoClient.searchTeamUsers.mockResolvedValue(Object.values(state.users.boardUsers))
@ -285,6 +286,12 @@ describe('src/components/workspace', () => {
featureFlags: {},
},
},
sidebar: {
categoryAttributes: [
categoryAttribute1,
],
hiddenBoardIDs: [],
},
})
let container: Element | undefined
await act(async () => {
@ -388,6 +395,7 @@ describe('src/components/workspace', () => {
categoryAttributes: [
categoryAttribute1,
],
hiddenBoardIDs: [],
},
}
const localStore = mockStateStore([thunk], localState)
@ -492,6 +500,7 @@ describe('src/components/workspace', () => {
categoryAttributes: [
categoryAttribute1,
],
hiddenBoardIDs: [],
},
}
const localStore = mockStateStore([thunk], localState)
@ -601,6 +610,7 @@ describe('src/components/workspace', () => {
categoryAttributes: [
categoryAttribute1,
],
hiddenBoardIDs: [],
},
}
const localStore = mockStateStore([thunk], localState)

View File

@ -25,7 +25,9 @@ import {Utils} from '../utils'
import {IUser} from '../user'
import propsRegistry from '../properties'
import {getMe, getMyConfig} from '../store/users'
import {getMe} from '../store/users'
import {getHiddenBoardIDs} from '../store/sidebar'
import CenterPanel from './centerPanel'
import BoardTemplateSelector from './boardTemplateSelector/boardTemplateSelector'
@ -54,12 +56,11 @@ function CenterContent(props: Props) {
const cardLimitTimestamp = useAppSelector(getCardLimitTimestamp)
const history = useHistory()
const dispatch = useAppDispatch()
const myConfig = useAppSelector(getMyConfig)
const me = useAppSelector<IUser|null>(getMe)
const hiddenBoardIDs = useAppSelector(getHiddenBoardIDs)
const isBoardHidden = () => {
const hiddenBoardIDs = myConfig.hiddenBoardIDs?.value || {}
return hiddenBoardIDs[board.id]
return hiddenBoardIDs.includes(board.id)
}
const showCard = useCallback((cardId?: string) => {

View File

@ -1033,6 +1033,22 @@ class OctoClient {
body: '{}',
})
}
async hideBoard(categoryID: string, boardID: string): Promise<Response> {
const path = `${this.teamPath()}/categories/${categoryID}/boards/${boardID}/hide`
return fetch(this.getBaseURL() + path, {
method: 'PUT',
headers: this.headers(),
})
}
async unhideBoard(categoryID: string, boardID: string): Promise<Response> {
const path = `${this.teamPath()}/categories/${categoryID}/boards/${boardID}/unhide`
return fetch(this.getBaseURL() + path, {
method: 'PUT',
headers: this.headers(),
})
}
}
const octoClient = new OctoClient()

View File

@ -12,7 +12,7 @@ import octoClient from '../../octoClient'
import {Subscription, WSClient} from '../../wsclient'
import {Utils} from '../../utils'
import {useWebsockets} from '../../hooks/websockets'
import {IUser, UserConfigPatch} from '../../user'
import {IUser} from '../../user'
import {Block} from '../../blocks/block'
import {ContentBlock} from '../../blocks/contentBlock'
import {CommentBlock} from '../../blocks/commentBlock'
@ -40,7 +40,7 @@ import {
fetchUserBlockSubscriptions,
getMe,
followBlock,
unfollowBlock, patchProps, getMyConfig,
unfollowBlock,
} from '../../store/users'
import {setGlobalError} from '../../store/globalError'
import {UserSettings} from '../../userSettings'
@ -52,6 +52,8 @@ import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../teleme
import {Constants} from '../../constants'
import {getCategoryOfBoard, getHiddenBoardIDs} from '../../store/sidebar'
import SetWindowTitleAndIcon from './setWindowTitleAndIcon'
import TeamToBoardAndViewRedirect from './teamToBoardAndViewRedirect'
import UndoRedoHotKeys from './undoRedoHotKeys'
@ -75,7 +77,8 @@ const BoardPage = (props: Props): JSX.Element => {
const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId
const viewId = match.params.viewId
const me = useAppSelector<IUser|null>(getMe)
const myConfig = useAppSelector(getMyConfig)
const hiddenBoardIDs = useAppSelector(getHiddenBoardIDs)
const category = useAppSelector(getCategoryOfBoard(activeBoardId))
// if we're in a legacy route and not showing a shared board,
// redirect to the new URL schema equivalent
@ -220,28 +223,11 @@ const BoardPage = (props: Props): JSX.Element => {
}, [teamId, match.params.boardId, viewId, me?.id])
const handleUnhideBoard = async (boardID: string) => {
Utils.log('handleUnhideBoard called')
if (!me) {
if (!me || !category) {
return
}
const hiddenBoards = {...(myConfig.hiddenBoardIDs ? myConfig.hiddenBoardIDs.value : {})}
// const index = hiddenBoards.indexOf(boardID)
// hiddenBoards.splice(index, 1)
delete hiddenBoards[boardID]
const hiddenBoardsArray = Object.keys(hiddenBoards)
const patch: UserConfigPatch = {
updatedFields: {
hiddenBoardIDs: JSON.stringify(hiddenBoardsArray),
},
}
const patchedProps = await octoClient.patchUserConfig(me.id, patch)
if (!patchedProps) {
return
}
await dispatch(patchProps(patchedProps))
await octoClient.unhideBoard(category.id, boardID)
}
useEffect(() => {
@ -249,8 +235,7 @@ const BoardPage = (props: Props): JSX.Element => {
return
}
const hiddenBoardIDs = myConfig.hiddenBoardIDs?.value || {}
if (hiddenBoardIDs[match.params.boardId]) {
if (hiddenBoardIDs.indexOf(match.params.boardId) >= 0) {
handleUnhideBoard(match.params.boardId)
}
}, [me?.id, teamId, match.params.boardId])

View File

@ -32,10 +32,10 @@ const TeamToBoardAndViewRedirect = (): null => {
let goToBoardID: string | null = null
for (const category of categories) {
for (const categoryBoardID of category.boardIDs) {
if (boards[categoryBoardID]) {
// pick the first category board that exists
goToBoardID = categoryBoardID
for (const boardMetadata of category.boardMetadata) {
// pick the first category board that exists and is not hidden
if (!boardMetadata.hidden && boards[boardMetadata.boardID]) {
goToBoardID = boardMetadata.boardID
break
}
}

View File

@ -25,18 +25,24 @@ interface Category {
isNew: boolean
}
interface CategoryBoardMetadata {
boardID: string
hidden: boolean
}
interface CategoryBoards extends Category {
boardIDs: string[]
boardMetadata: CategoryBoardMetadata[]
}
interface BoardCategoryWebsocketData {
boardID: string
categoryID: string
hidden: boolean
}
interface CategoryBoardsReorderData {
categoryID: string
boardIDs: string[]
boardsMetadata: CategoryBoardMetadata[]
}
export const DefaultCategory: CategoryBoards = {
@ -53,11 +59,12 @@ export const fetchSidebarCategories = createAsyncThunk(
type Sidebar = {
categoryAttributes: CategoryBoards[]
hiddenBoardIDs: string[]
}
const sidebarSlice = createSlice({
name: 'sidebar',
initialState: {categoryAttributes: []} as Sidebar,
initialState: {categoryAttributes: [], hiddenBoardIDs: []} as Sidebar,
reducers: {
updateCategories: (state, action: PayloadAction<Category[]>) => {
action.payload.forEach((updatedCategory) => {
@ -68,7 +75,7 @@ const sidebarSlice = createSlice({
// new categories should always show up on the top
state.categoryAttributes.unshift({
...updatedCategory,
boardIDs: [],
boardMetadata: [],
isNew: true,
})
} else if (updatedCategory.deleteAt) {
@ -87,31 +94,44 @@ const sidebarSlice = createSlice({
},
updateBoardCategories: (state, action: PayloadAction<BoardCategoryWebsocketData[]>) => {
const updatedCategoryAttributes: CategoryBoards[] = []
let updatedHiddenBoardIDs = state.hiddenBoardIDs
action.payload.forEach((boardCategory) => {
for (let i = 0; i < state.categoryAttributes.length; i++) {
const categoryAttribute = state.categoryAttributes[i]
if (categoryAttribute.id === boardCategory.categoryID) {
// if board is already in the right category, don't do anything
// and let the board stay in its right order.
// Only if its not in the right category, do add it.
if (categoryAttribute.boardIDs.indexOf(boardCategory.boardID) < 0) {
categoryAttribute.boardIDs.unshift(boardCategory.boardID)
const categoryBoardMetadataIndex = categoryAttribute.boardMetadata.findIndex((boardMetadata) => boardMetadata.boardID === boardCategory.boardID)
if (categoryBoardMetadataIndex >= 0) {
categoryAttribute.boardMetadata[categoryBoardMetadataIndex] = {
...categoryAttribute.boardMetadata[categoryBoardMetadataIndex],
hidden: boardCategory.hidden,
}
} else {
categoryAttribute.boardMetadata.unshift({boardID: boardCategory.boardID, hidden: boardCategory.hidden})
categoryAttribute.isNew = false
}
} else {
// remove the board from other categories
categoryAttribute.boardIDs = categoryAttribute.boardIDs.filter((boardID) => boardID !== boardCategory.boardID)
categoryAttribute.boardMetadata = categoryAttribute.boardMetadata.filter((metadata) => metadata.boardID !== boardCategory.boardID)
}
updatedCategoryAttributes[i] = categoryAttribute
if (boardCategory.hidden) {
if (updatedHiddenBoardIDs.indexOf(boardCategory.boardID) < 0) {
updatedHiddenBoardIDs.push(boardCategory.boardID)
}
} else {
updatedHiddenBoardIDs = updatedHiddenBoardIDs.filter((hiddenBoardID) => hiddenBoardID !== boardCategory.boardID)
}
}
})
if (updatedCategoryAttributes.length > 0) {
state.categoryAttributes = updatedCategoryAttributes
}
state.hiddenBoardIDs = updatedHiddenBoardIDs
},
updateCategoryOrder: (state, action: PayloadAction<string[]>) => {
if (action.payload.length === 0) {
@ -134,7 +154,7 @@ const sidebarSlice = createSlice({
state.categoryAttributes = newOrderedCategories
},
updateCategoryBoardsOrder: (state, action: PayloadAction<CategoryBoardsReorderData>) => {
if (action.payload.boardIDs.length === 0) {
if (action.payload.boardsMetadata.length === 0) {
return
}
@ -145,9 +165,9 @@ const sidebarSlice = createSlice({
}
const category = state.categoryAttributes[categoryIndex]
const updatedCategory = {
const updatedCategory: CategoryBoards = {
...category,
boardIDs: action.payload.boardIDs,
boardMetadata: action.payload.boardsMetadata,
isNew: false,
}
@ -158,6 +178,17 @@ const sidebarSlice = createSlice({
extraReducers: (builder) => {
builder.addCase(fetchSidebarCategories.fulfilled, (state, action) => {
state.categoryAttributes = action.payload || []
state.hiddenBoardIDs = state.categoryAttributes.flatMap(
(ca) => {
return ca.boardMetadata.reduce((collector, m) => {
if (m.hidden) {
collector.push(m.boardID)
}
return collector
}, [] as string[])
},
)
})
},
})
@ -167,9 +198,18 @@ export const getSidebarCategories = createSelector(
(sidebarCategories) => sidebarCategories,
)
export const getHiddenBoardIDs = (state: RootState): string[] => state.sidebar.hiddenBoardIDs
export function getCategoryOfBoard(boardID: string): (state: RootState) => CategoryBoards | undefined {
return createSelector(
(state: RootState): CategoryBoards[] => state.sidebar.categoryAttributes,
(sidebarCategories) => sidebarCategories.find((category) => category.boardMetadata.findIndex((m) => m.boardID === boardID) >= 0),
)
}
export const {reducer} = sidebarSlice
export const {updateCategories, updateBoardCategories, updateCategoryOrder, updateCategoryBoardsOrder} = sidebarSlice.actions
export {Category, CategoryBoards, BoardCategoryWebsocketData, CategoryBoardsReorderData}
export {Category, CategoryBoards, BoardCategoryWebsocketData, CategoryBoardsReorderData, CategoryBoardMetadata}

View File

@ -182,7 +182,7 @@ class TestBlockFactory {
static createCategoryBoards(): CategoryBoards {
return {
...TestBlockFactory.createCategory(),
boardIDs: [],
boardMetadata: [],
}
}