diff --git a/server/api/categories.go b/server/api/categories.go index 9181c42d5..061b3aeac 100644 --- a/server/api/categories.go +++ b/server/api/categories.go @@ -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() +} diff --git a/server/app/boards.go b/server/app/boards.go index f9ea5085a..479926447 100644 --- a/server/app/boards.go +++ b/server/app/boards.go @@ -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 } diff --git a/server/app/boards_test.go b/server/app/boards_test.go index e41e30fea..51bb88296 100644 --- a/server/app/boards_test.go +++ b/server/app/boards_test.go @@ -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,45 +411,72 @@ 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"}, + 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"}, + 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"}, + }, + { + 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"}, - BoardIDs: []string{}, + }, + { + Category: model.Category{ID: "category_id_3", Name: "Category 3"}, + BoardMetadata: []model.CategoryBoardMetadata{}, + }, + }, 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"}, }, - }, nil) + }, + { + 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{ - ID: "default_category_id", - Name: "Boards", - }, 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", - }).Return(nil) + th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) + th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ + ID: "default_category_id", + Name: "Boards", + }, 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", "default_category_id", []string{ + "board_id_1", + "board_id_2", + "board_id_3", + }).Return(nil) - boards := []*model.Board{ - {ID: "board_id_1"}, - {ID: "board_id_2"}, - {ID: "board_id_3"}, - } + boards := []*model.Board{ + {ID: "board_id_1"}, + {ID: "board_id_2"}, + {ID: "board_id_3"}, + } - err := th.App.addBoardsToDefaultCategory("user_id", "team_id", boards) - assert.NoError(t, err) - }) + err := th.App.addBoardsToDefaultCategory("user_id", "team_id", boards) + assert.NoError(t, err) }) } @@ -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) diff --git a/server/app/category.go b/server/app/category.go index 6fbd0bb10..f91591762 100644 --- a/server/app/category.go +++ b/server/app/category.go @@ -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) } diff --git a/server/app/category_boards.go b/server/app/category_boards.go index 79728716f..e8a5cca09 100644 --- a/server/app/category_boards.go +++ b/server/app/category_boards.go @@ -79,8 +79,8 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards } createdCategoryBoards := &model.CategoryBoards{ - Category: *createdCategory, - BoardIDs: []string{}, + Category: *createdCategory, + 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 +} diff --git a/server/app/category_boards_test.go b/server/app/category_boards_test.go index e495a8ff2..1ff0c2bf1 100644 --- a/server/app/category_boards_test.go +++ b/server/app/category_boards_test.go @@ -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{}, + Category: model.Category{ID: "category_id_3", Name: "Category 3"}, + 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{}, + Category: model.Category{ID: "category_id_3", Name: "Category 3"}, + BoardMetadata: []model.CategoryBoardMetadata{}, }, }, nil) diff --git a/server/app/category_test.go b/server/app/category_test.go index 34588aad5..04b1bade0 100644 --- a/server/app/category_test.go +++ b/server/app/category_test.go @@ -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) diff --git a/server/app/import_test.go b/server/app/import_test.go index f703e1305..7e11d6504 100644 --- a/server/app/import_test.go +++ b/server/app/import_test.go @@ -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) diff --git a/server/app/onboarding_test.go b/server/app/onboarding_test.go index 29c2597e2..90a77170d 100644 --- a/server/app/onboarding_test.go +++ b/server/app/onboarding_test.go @@ -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) diff --git a/server/client/client.go b/server/client/client.go index 00923b7fd..002ff4cf8 100644 --- a/server/client/client.go +++ b/server/client/client.go @@ -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) +} diff --git a/server/integrationtests/board_test.go b/server/integrationtests/board_test.go index b1cf9928e..a45033c92 100644 --- a/server/integrationtests/board_test.go +++ b/server/integrationtests/board_test.go @@ -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 } } diff --git a/server/integrationtests/sidebar_test.go b/server/integrationtests/sidebar_test.go index 976f1aa9c..c251b2f8b 100644 --- a/server/integrationtests/sidebar_test.go +++ b/server/integrationtests/sidebar_test.go @@ -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) } diff --git a/server/model/category_boards.go b/server/model/category_boards.go index 81cd568a5..b01ee0d11 100644 --- a/server/model/category_boards.go +++ b/server/model/category_boards.go @@ -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"` } diff --git a/server/model/properties.go b/server/model/properties.go index 8bf30cd03..1c8c764be 100644 --- a/server/model/properties.go +++ b/server/model/properties.go @@ -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 ( diff --git a/server/services/store/mockstore/mockstore.go b/server/services/store/mockstore/mockstore.go index be3c588d4..ae4d45d19 100644 --- a/server/services/store/mockstore/mockstore.go +++ b/server/services/store/mockstore/mockstore.go @@ -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() diff --git a/server/services/store/sqlstore/category_boards.go b/server/services/store/sqlstore/category_boards.go index 6a8415c67..b1e396b66 100644 --- a/server/services/store/sqlstore/category_boards.go +++ b/server/services/store/sqlstore/category_boards.go @@ -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, + Category: category, + 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,73 +76,62 @@ 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( - utils.NewID(utils.IDTypeNone), - userID, - categoryID, - boardID, - now, - now, - 0, - 0, - ) + for _, boardID := range boardIDs { + query = query.Values( + utils.NewID(utils.IDTypeNone), + userID, + categoryID, + boardID, + now, + now, + 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 +} diff --git a/server/services/store/sqlstore/migrate.go b/server/services/store/sqlstore/migrate.go index 6c95481ac..afd9463ac 100644 --- a/server/services/store/sqlstore/migrate.go +++ b/server/services/store/sqlstore/migrate.go @@ -295,13 +295,14 @@ func (s *SQLStore) ensureMigrationsAppliedUpToVersion(engine *morph.Morph, drive func (s *SQLStore) GetTemplateHelperFuncs() template.FuncMap { funcs := template.FuncMap{ - "addColumnIfNeeded": s.genAddColumnIfNeeded, - "dropColumnIfNeeded": s.genDropColumnIfNeeded, - "createIndexIfNeeded": s.genCreateIndexIfNeeded, - "renameTableIfNeeded": s.genRenameTableIfNeeded, - "renameColumnIfNeeded": s.genRenameColumnIfNeeded, - "doesTableExist": s.doesTableExist, - "doesColumnExist": s.doesColumnExist, + "addColumnIfNeeded": s.genAddColumnIfNeeded, + "dropColumnIfNeeded": s.genDropColumnIfNeeded, + "createIndexIfNeeded": s.genCreateIndexIfNeeded, + "renameTableIfNeeded": s.genRenameTableIfNeeded, + "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 diff --git a/server/services/store/sqlstore/migrations/000033_remove_deleted_category_boards.down.sql b/server/services/store/sqlstore/migrations/000033_remove_deleted_category_boards.down.sql new file mode 100644 index 000000000..027b7d63f --- /dev/null +++ b/server/services/store/sqlstore/migrations/000033_remove_deleted_category_boards.down.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000033_remove_deleted_category_boards.up.sql b/server/services/store/sqlstore/migrations/000033_remove_deleted_category_boards.up.sql new file mode 100644 index 000000000..1714ace0c --- /dev/null +++ b/server/services/store/sqlstore/migrations/000033_remove_deleted_category_boards.up.sql @@ -0,0 +1 @@ +DELETE FROM {{.prefix}}category_boards WHERE delete_at > 0; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000034_category_boards_remove_unused_delete_at_column.down.sql b/server/services/store/sqlstore/migrations/000034_category_boards_remove_unused_delete_at_column.down.sql new file mode 100644 index 000000000..027b7d63f --- /dev/null +++ b/server/services/store/sqlstore/migrations/000034_category_boards_remove_unused_delete_at_column.down.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000034_category_boards_remove_unused_delete_at_column.up.sql b/server/services/store/sqlstore/migrations/000034_category_boards_remove_unused_delete_at_column.up.sql new file mode 100644 index 000000000..215b9c762 --- /dev/null +++ b/server/services/store/sqlstore/migrations/000034_category_boards_remove_unused_delete_at_column.up.sql @@ -0,0 +1,3 @@ +{{ if or .postgres .mysql }} + {{ dropColumnIfNeeded "category_boards" "delete_at" }} +{{end}} \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000035_add_hidden_board_column.down.sql b/server/services/store/sqlstore/migrations/000035_add_hidden_board_column.down.sql new file mode 100644 index 000000000..027b7d63f --- /dev/null +++ b/server/services/store/sqlstore/migrations/000035_add_hidden_board_column.down.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000035_add_hidden_board_column.up.sql b/server/services/store/sqlstore/migrations/000035_add_hidden_board_column.up.sql new file mode 100644 index 000000000..47140be28 --- /dev/null +++ b/server/services/store/sqlstore/migrations/000035_add_hidden_board_column.up.sql @@ -0,0 +1 @@ +{{ addColumnIfNeeded "category_boards" "hidden" "boolean" "" }} \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000036_category_board_add_unique_constraint.down.sql b/server/services/store/sqlstore/migrations/000036_category_board_add_unique_constraint.down.sql new file mode 100644 index 000000000..027b7d63f --- /dev/null +++ b/server/services/store/sqlstore/migrations/000036_category_board_add_unique_constraint.down.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000036_category_board_add_unique_constraint.up.sql b/server/services/store/sqlstore/migrations/000036_category_board_add_unique_constraint.up.sql new file mode 100644 index 000000000..8858033b0 --- /dev/null +++ b/server/services/store/sqlstore/migrations/000036_category_board_add_unique_constraint.up.sql @@ -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}} \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000037_hidden_boards_from_preferences.down.sql b/server/services/store/sqlstore/migrations/000037_hidden_boards_from_preferences.down.sql new file mode 100644 index 000000000..027b7d63f --- /dev/null +++ b/server/services/store/sqlstore/migrations/000037_hidden_boards_from_preferences.down.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000037_hidden_boards_from_preferences.up.sql b/server/services/store/sqlstore/migrations/000037_hidden_boards_from_preferences.up.sql new file mode 100644 index 000000000..b9bd56bb1 --- /dev/null +++ b/server/services/store/sqlstore/migrations/000037_hidden_boards_from_preferences.up.sql @@ -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}} \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000038_delete_hiddenBoardIDs_from_preferences.down.sql b/server/services/store/sqlstore/migrations/000038_delete_hiddenBoardIDs_from_preferences.down.sql new file mode 100644 index 000000000..027b7d63f --- /dev/null +++ b/server/services/store/sqlstore/migrations/000038_delete_hiddenBoardIDs_from_preferences.down.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000038_delete_hiddenBoardIDs_from_preferences.up.sql b/server/services/store/sqlstore/migrations/000038_delete_hiddenBoardIDs_from_preferences.up.sql new file mode 100644 index 000000000..f86eb0c9e --- /dev/null +++ b/server/services/store/sqlstore/migrations/000038_delete_hiddenBoardIDs_from_preferences.up.sql @@ -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}} \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test33_with_deleted_data.sql b/server/services/store/sqlstore/migrationstests/fixtures/test33_with_deleted_data.sql new file mode 100644 index 000000000..c5593660b --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/fixtures/test33_with_deleted_data.sql @@ -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); \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test33_with_no_deleted_data.sql b/server/services/store/sqlstore/migrationstests/fixtures/test33_with_no_deleted_data.sql new file mode 100644 index 000000000..5f4337bb2 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/fixtures/test33_with_no_deleted_data.sql @@ -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); \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test34_drop_delete_at_column.sql b/server/services/store/sqlstore/migrationstests/fixtures/test34_drop_delete_at_column.sql new file mode 100644 index 000000000..4ab1ccbc8 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/fixtures/test34_drop_delete_at_column.sql @@ -0,0 +1 @@ +ALTER TABLE focalboard_category_boards DROP COLUMN delete_at; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test35_add_hidden_column.sql b/server/services/store/sqlstore/migrationstests/fixtures/test35_add_hidden_column.sql new file mode 100644 index 000000000..1896a0416 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/fixtures/test35_add_hidden_column.sql @@ -0,0 +1 @@ +ALTER TABLE focalboard_category_boards ADD COLUMN hidden boolean; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test36_add_unique_constraint.sql b/server/services/store/sqlstore/migrationstests/fixtures/test36_add_unique_constraint.sql new file mode 100644 index 000000000..e69de29bb diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data.sql b/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data.sql new file mode 100644 index 000000000..8f75da315 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data.sql @@ -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"]'); \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_no_hidden_boards.sql b/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_no_hidden_boards.sql new file mode 100644 index 000000000..2986bad28 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_no_hidden_boards.sql @@ -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); \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_preference_but_no_hidden_board.sql b/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_preference_but_no_hidden_board.sql new file mode 100644 index 000000000..943932e54 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_preference_but_no_hidden_board.sql @@ -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', ''); \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_sqlite.sql b/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_sqlite.sql new file mode 100644 index 000000000..114273c30 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_sqlite.sql @@ -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"]'); \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_sqlite_preference_but_no_hidden_board.sql b/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_sqlite_preference_but_no_hidden_board.sql new file mode 100644 index 000000000..4b45cacdf --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/fixtures/test37_valid_data_sqlite_preference_but_no_hidden_board.sql @@ -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', ''); \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test38_add_plugin_preferences.sql b/server/services/store/sqlstore/migrationstests/fixtures/test38_add_plugin_preferences.sql new file mode 100644 index 000000000..600be118a --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/fixtures/test38_add_plugin_preferences.sql @@ -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', ''); \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/fixtures/test38_add_standalone_preferences.sql b/server/services/store/sqlstore/migrationstests/fixtures/test38_add_standalone_preferences.sql new file mode 100644 index 000000000..422a967c3 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/fixtures/test38_add_standalone_preferences.sql @@ -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', ''); \ No newline at end of file diff --git a/server/services/store/sqlstore/migrationstests/helpers_test.go b/server/services/store/sqlstore/migrationstests/helpers_test.go index 673ff3bff..b3356f9d4 100644 --- a/server/services/store/sqlstore/migrationstests/helpers_test.go +++ b/server/services/store/sqlstore/migrationstests/helpers_test.go @@ -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 { diff --git a/server/services/store/sqlstore/migrationstests/migrate_34_test.go b/server/services/store/sqlstore/migrationstests/migrate_34_test.go new file mode 100644 index 000000000..5e82386d1 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/migrate_34_test.go @@ -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) + } + }) +} diff --git a/server/services/store/sqlstore/migrationstests/migration35_test.go b/server/services/store/sqlstore/migrationstests/migration35_test.go new file mode 100644 index 000000000..685478010 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/migration35_test.go @@ -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) + }) +} diff --git a/server/services/store/sqlstore/migrationstests/migration36_test.go b/server/services/store/sqlstore/migrationstests/migration36_test.go new file mode 100644 index 000000000..5d1af8bb5 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/migration36_test.go @@ -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) + }) +} diff --git a/server/services/store/sqlstore/migrationstests/migration37_test.go b/server/services/store/sqlstore/migrationstests/migration37_test.go new file mode 100644 index 000000000..9958c257b --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/migration37_test.go @@ -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) + }) + +} diff --git a/server/services/store/sqlstore/migrationstests/migration38_test.go b/server/services/store/sqlstore/migrationstests/migration38_test.go new file mode 100644 index 000000000..d58f73bff --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/migration38_test.go @@ -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) + }) +} diff --git a/server/services/store/sqlstore/migrationstests/migration_33_test.go b/server/services/store/sqlstore/migrationstests/migration_33_test.go new file mode 100644 index 000000000..ccbdeaa30 --- /dev/null +++ b/server/services/store/sqlstore/migrationstests/migration_33_test.go @@ -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) + + }) +} diff --git a/server/services/store/sqlstore/public_methods.go b/server/services/store/sqlstore/public_methods.go index e484d676a..139105fad 100644 --- a/server/services/store/sqlstore/public_methods.go +++ b/server/services/store/sqlstore/public_methods.go @@ -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) diff --git a/server/services/store/store.go b/server/services/store/store.go index eb9fabedb..65f41613a 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -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 diff --git a/server/services/store/storetests/category.go b/server/services/store/storetests/category.go index 113e13b2b..bf09da297 100644 --- a/server/services/store/storetests/category.go +++ b/server/services/store/storetests/category.go @@ -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) } diff --git a/server/services/store/storetests/categoryBoards.go b/server/services/store/storetests/categoryBoards.go index 2cc83b197..96b1d34ec 100644 --- a/server/services/store/storetests/categoryBoards.go +++ b/server/services/store/storetests/categoryBoards.go @@ -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) +} diff --git a/server/services/store/storetests/data_retention.go b/server/services/store/storetests/data_retention.go index a73d40afa..2f7f36526 100644 --- a/server/services/store/storetests/data_retention.go +++ b/server/services/store/storetests/data_retention.go @@ -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) } diff --git a/server/utils/utils.go b/server/utils/utils.go index 995c2cc87..4a93e1a67 100644 --- a/server/utils/utils.go +++ b/server/utils/utils.go @@ -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 +} diff --git a/webapp/src/components/shareBoard/shareBoard.test.tsx b/webapp/src/components/shareBoard/shareBoard.test.tsx index ec139ad29..5e59efe1c 100644 --- a/webapp/src/components/shareBoard/shareBoard.test.tsx +++ b/webapp/src/components/shareBoard/shareBoard.test.tsx @@ -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) diff --git a/webapp/src/components/sidebar/sidebar.test.tsx b/webapp/src/components/sidebar/sidebar.test.tsx index 1d96f5a5a..a7ea17878 100644 --- a/webapp/src/components/sidebar/sidebar.test.tsx +++ b/webapp/src/components/sidebar/sidebar.test.tsx @@ -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: { diff --git a/webapp/src/components/sidebar/sidebar.tsx b/webapp/src/components/sidebar/sidebar.tsx index 558cdd0b4..bd50edfdc 100644 --- a/webapp/src/components/sidebar/sidebar.tsx +++ b/webapp/src/components/sidebar/sidebar.tsx @@ -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() 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) } diff --git a/webapp/src/components/sidebar/sidebarBoardItem.test.tsx b/webapp/src/components/sidebar/sidebarBoardItem.test.tsx index 0b09eaa2e..04add6a6d 100644 --- a/webapp/src/components/sidebar/sidebarBoardItem.test.tsx +++ b/webapp/src/components/sidebar/sidebarBoardItem.test.tsx @@ -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' diff --git a/webapp/src/components/sidebar/sidebarBoardItem.tsx b/webapp/src/components/sidebar/sidebarBoardItem.tsx index 4df5c8057..c1d94b87d 100644 --- a/webapp/src/components/sidebar/sidebarBoardItem.tsx +++ b/webapp/src/components/sidebar/sidebarBoardItem.tsx @@ -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) } } } diff --git a/webapp/src/components/sidebar/sidebarCategory.test.tsx b/webapp/src/components/sidebar/sidebarCategory.test.tsx index 22e3aaecd..ee25b9fc2 100644 --- a/webapp/src/components/sidebar/sidebarCategory.test.tsx +++ b/webapp/src/components/sidebar/sidebarCategory.test.tsx @@ -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' diff --git a/webapp/src/components/sidebar/sidebarCategory.tsx b/webapp/src/components/sidebar/sidebarCategory.tsx index d95b59d62..c8662537e 100644 --- a/webapp/src/components/sidebar/sidebarCategory.tsx +++ b/webapp/src/components/sidebar/sidebarCategory.tsx @@ -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) diff --git a/webapp/src/components/workspace.test.tsx b/webapp/src/components/workspace.test.tsx index 94e7bca28..a4c26ef3b 100644 --- a/webapp/src/components/workspace.test.tsx +++ b/webapp/src/components/workspace.test.tsx @@ -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) diff --git a/webapp/src/components/workspace.tsx b/webapp/src/components/workspace.tsx index 303269716..1c44a889c 100644 --- a/webapp/src/components/workspace.tsx +++ b/webapp/src/components/workspace.tsx @@ -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(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) => { diff --git a/webapp/src/octoClient.ts b/webapp/src/octoClient.ts index d0fdfdbd7..9497a1a38 100644 --- a/webapp/src/octoClient.ts +++ b/webapp/src/octoClient.ts @@ -1033,6 +1033,22 @@ class OctoClient { body: '{}', }) } + + async hideBoard(categoryID: string, boardID: string): Promise { + 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 { + const path = `${this.teamPath()}/categories/${categoryID}/boards/${boardID}/unhide` + return fetch(this.getBaseURL() + path, { + method: 'PUT', + headers: this.headers(), + }) + } } const octoClient = new OctoClient() diff --git a/webapp/src/pages/boardPage/boardPage.tsx b/webapp/src/pages/boardPage/boardPage.tsx index 394d83651..af3806d25 100644 --- a/webapp/src/pages/boardPage/boardPage.tsx +++ b/webapp/src/pages/boardPage/boardPage.tsx @@ -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(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]) diff --git a/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx b/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx index 3a29a50b5..a8d17173b 100644 --- a/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx +++ b/webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx @@ -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 } } diff --git a/webapp/src/store/sidebar.ts b/webapp/src/store/sidebar.ts index 2b1432254..212084a4e 100644 --- a/webapp/src/store/sidebar.ts +++ b/webapp/src/store/sidebar.ts @@ -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) => { 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) => { 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) => { if (action.payload.length === 0) { @@ -134,7 +154,7 @@ const sidebarSlice = createSlice({ state.categoryAttributes = newOrderedCategories }, updateCategoryBoardsOrder: (state, action: PayloadAction) => { - 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} diff --git a/webapp/src/test/testBlockFactory.ts b/webapp/src/test/testBlockFactory.ts index f6a9bd8c7..de5ca6b05 100644 --- a/webapp/src/test/testBlockFactory.ts +++ b/webapp/src/test/testBlockFactory.ts @@ -182,7 +182,7 @@ class TestBlockFactory { static createCategoryBoards(): CategoryBoards { return { ...TestBlockFactory.createCategory(), - boardIDs: [], + boardMetadata: [], } }