You've already forked focalboard
							
							
				mirror of
				https://github.com/mattermost/focalboard.git
				synced 2025-10-31 00:17:42 +02:00 
			
		
		
		
	Hide board feature (#4409)
* WIP * Added migrations * Updating store method * WIP * WIP * Updated DND * WIP * WIP * WIP * WIP * WIP * wip * WIP * Adding new DB tool * Used migration functions in new migrations * Unique constraint migration * Unique constraint migration * Added SQLITE migrations * Added SQLITE support in few more migrations * Added SQLITE support in few more migrations * WIP * Used old-fashioned way to add unique constraint * Using oldsqlite method * Using oldsqlite method * Fixed all store and app layer tests * fixed integration tests * test and lint fix * Updated migration for MySQL and Postgres on personal server * Types fix * sqlite fix * fix typo * misc cleanup * added new tests * added new tests * de-duping input for postgres * integration tests, rmeoved uneeded migration * Added some migration tests * Added some migration tests * Fixed a test * completed migration tests * completed migration tests * Removed leftover debug statements Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
		| @@ -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() | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| 				} | ||||
| 			} | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -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"` | ||||
| } | ||||
|   | ||||
| @@ -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 ( | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -0,0 +1 @@ | ||||
| SELECT 1; | ||||
| @@ -0,0 +1 @@ | ||||
| DELETE FROM {{.prefix}}category_boards WHERE delete_at > 0; | ||||
| @@ -0,0 +1 @@ | ||||
| SELECT 1; | ||||
| @@ -0,0 +1,3 @@ | ||||
| {{ if or .postgres .mysql }} | ||||
|     {{ dropColumnIfNeeded "category_boards" "delete_at" }} | ||||
| {{end}} | ||||
| @@ -0,0 +1 @@ | ||||
| SELECT 1; | ||||
| @@ -0,0 +1 @@ | ||||
| {{ addColumnIfNeeded "category_boards" "hidden" "boolean" "" }} | ||||
| @@ -0,0 +1 @@ | ||||
| SELECT 1; | ||||
| @@ -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}} | ||||
| @@ -0,0 +1 @@ | ||||
| SELECT 1; | ||||
| @@ -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}} | ||||
| @@ -0,0 +1 @@ | ||||
| SELECT 1; | ||||
| @@ -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}} | ||||
| @@ -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); | ||||
| @@ -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); | ||||
| @@ -0,0 +1 @@ | ||||
| ALTER TABLE focalboard_category_boards DROP COLUMN delete_at; | ||||
| @@ -0,0 +1 @@ | ||||
| ALTER TABLE focalboard_category_boards ADD COLUMN hidden boolean; | ||||
| @@ -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"]'); | ||||
| @@ -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); | ||||
| @@ -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', ''); | ||||
| @@ -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"]'); | ||||
| @@ -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', ''); | ||||
| @@ -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', ''); | ||||
| @@ -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', ''); | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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) | ||||
| 		} | ||||
| 	}) | ||||
| } | ||||
| @@ -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) | ||||
| 	}) | ||||
| } | ||||
| @@ -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) | ||||
| 	}) | ||||
| } | ||||
| @@ -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) | ||||
| 	}) | ||||
|  | ||||
| } | ||||
| @@ -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) | ||||
| 	}) | ||||
| } | ||||
| @@ -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) | ||||
|  | ||||
| 	}) | ||||
| } | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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: { | ||||
|   | ||||
| @@ -124,8 +124,8 @@ const Sidebar = (props: Props) => { | ||||
|     // and thats the first time that user is opening that board. | ||||
|     // Here we check if that board has a associated category for the user. If not, | ||||
|     // we assign it to the default "Boards" category. | ||||
|     // We do this on the client side rather than the server side live for all other cases | ||||
|     // is because there is no good, explicit API call to add this logic to when opening | ||||
|     // We do this on the client side rather than the server side like for all other cases | ||||
|     // because there is no good, explicit API call to add this logic to when opening | ||||
|     // a board that you have implicit access to. | ||||
|     useEffect(() => { | ||||
|         if (!sidebarCategories || sidebarCategories.length === 0 || !currentBoard || !team || currentBoard.isTemplate) { | ||||
| @@ -133,7 +133,8 @@ const Sidebar = (props: Props) => { | ||||
|         } | ||||
|  | ||||
|         // find the category the current board belongs to | ||||
|         const category = sidebarCategories.find((c) => c.boardIDs.indexOf(currentBoard.id) >= 0) | ||||
|         // const category = sidebarCategories.find((c) => c.boardIDs.indexOf(currentBoard.id) >= 0) | ||||
|         const category = sidebarCategories.find((c) => c.boardMetadata.find((boardMetadata) => boardMetadata.boardID === currentBoard.id)) | ||||
|         if (category) { | ||||
|             // Boards does belong to a category. | ||||
|             // All good here. Nothing to do | ||||
| @@ -219,29 +220,41 @@ const Sidebar = (props: Props) => { | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             const boardIDs = [...toSidebarCategory.boardIDs] | ||||
|             boardIDs.splice(source.index, 1) | ||||
|             boardIDs.splice(destination.index, 0, toSidebarCategory.boardIDs[source.index]) | ||||
|             const categoryBoardMetadata = [...toSidebarCategory.boardMetadata] | ||||
|             categoryBoardMetadata.splice(source.index, 1) | ||||
|             categoryBoardMetadata.splice(destination.index, 0, toSidebarCategory.boardMetadata[source.index]) | ||||
|  | ||||
|             dispatch(updateCategoryBoardsOrder({categoryID: toCategoryID, boardIDs})) | ||||
|             await octoClient.reorderSidebarCategoryBoards(team.id, toCategoryID, boardIDs) | ||||
|             dispatch(updateCategoryBoardsOrder({categoryID: toCategoryID, boardsMetadata: categoryBoardMetadata})) | ||||
|  | ||||
|             const reorderedBoardIDs = categoryBoardMetadata.map((m) => m.boardID) | ||||
|             await octoClient.reorderSidebarCategoryBoards(team.id, toCategoryID, reorderedBoardIDs) | ||||
|         } else { | ||||
|             // board moved to a different category | ||||
|             const toSidebarCategory = sidebarCategories.find((category) => category.id === toCategoryID) | ||||
|             const fromSidebarCategory = sidebarCategories.find((category) => category.id === fromCategoryID) | ||||
|  | ||||
|             if (!toSidebarCategory) { | ||||
|                 Utils.logError(`toCategoryID not found in list of sidebar categories. toCategoryID: ${toCategoryID}`) | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             const boardIDs = [...toSidebarCategory.boardIDs] | ||||
|             boardIDs.splice(destination.index, 0, boardID) | ||||
|             if (!fromSidebarCategory) { | ||||
|                 Utils.logError(`fromCategoryID not found in list of sidebar categories. fromCategoryID: ${fromCategoryID}`) | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             const categoryBoardMetadata = [...toSidebarCategory.boardMetadata] | ||||
|             const fromCategoryBoardMetadata = fromSidebarCategory.boardMetadata[source.index] | ||||
|             categoryBoardMetadata.splice(destination.index, 0, fromCategoryBoardMetadata) | ||||
|  | ||||
|             // optimistically updating the store to create a lag-free UI. | ||||
|             await dispatch(updateCategoryBoardsOrder({categoryID: toCategoryID, boardIDs})) | ||||
|             dispatch(updateBoardCategories([{boardID, categoryID: toCategoryID}])) | ||||
|             await dispatch(updateCategoryBoardsOrder({categoryID: toCategoryID, boardsMetadata: categoryBoardMetadata})) | ||||
|             dispatch(updateBoardCategories([{...fromCategoryBoardMetadata, categoryID: toCategoryID}])) | ||||
|  | ||||
|             await mutator.moveBoardToCategory(team.id, boardID, toCategoryID, fromCategoryID) | ||||
|             await octoClient.reorderSidebarCategoryBoards(team.id, toCategoryID, boardIDs) | ||||
|  | ||||
|             const reorderedBoardIDs = categoryBoardMetadata.map((m) => m.boardID) | ||||
|             await octoClient.reorderSidebarCategoryBoards(team.id, toCategoryID, reorderedBoardIDs) | ||||
|         } | ||||
|     }, [team, sidebarCategories]) | ||||
|  | ||||
| @@ -313,7 +326,7 @@ const Sidebar = (props: Props) => { | ||||
|     const getSortedCategoryBoards = (category: CategoryBoards): Board[] => { | ||||
|         const categoryBoardsByID = new Map<string, Board>() | ||||
|         boards.forEach((board) => { | ||||
|             if (!category.boardIDs.includes(board.id)) { | ||||
|             if (!category.boardMetadata.find((m) => m.boardID === board.id)) { | ||||
|                 return | ||||
|             } | ||||
|  | ||||
| @@ -321,8 +334,8 @@ const Sidebar = (props: Props) => { | ||||
|         }) | ||||
|  | ||||
|         const sortedBoards: Board[] = [] | ||||
|         category.boardIDs.forEach((boardID) => { | ||||
|             const b = categoryBoardsByID.get(boardID) | ||||
|         category.boardMetadata.forEach((boardMetadata) => { | ||||
|             const b = categoryBoardsByID.get(boardMetadata.boardID) | ||||
|             if (b) { | ||||
|                 sortedBoards.push(b) | ||||
|             } | ||||
|   | ||||
| @@ -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' | ||||
|   | ||||
| @@ -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) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -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' | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -25,7 +25,9 @@ import {Utils} from '../utils' | ||||
| import {IUser} from '../user' | ||||
| import propsRegistry from '../properties' | ||||
|  | ||||
| import {getMe, getMyConfig} from '../store/users' | ||||
| import {getMe} from '../store/users' | ||||
|  | ||||
| import {getHiddenBoardIDs} from '../store/sidebar' | ||||
|  | ||||
| import CenterPanel from './centerPanel' | ||||
| import BoardTemplateSelector from './boardTemplateSelector/boardTemplateSelector' | ||||
| @@ -54,12 +56,11 @@ function CenterContent(props: Props) { | ||||
|     const cardLimitTimestamp = useAppSelector(getCardLimitTimestamp) | ||||
|     const history = useHistory() | ||||
|     const dispatch = useAppDispatch() | ||||
|     const myConfig = useAppSelector(getMyConfig) | ||||
|     const me = useAppSelector<IUser|null>(getMe) | ||||
|     const hiddenBoardIDs = useAppSelector(getHiddenBoardIDs) | ||||
|  | ||||
|     const isBoardHidden = () => { | ||||
|         const hiddenBoardIDs = myConfig.hiddenBoardIDs?.value || {} | ||||
|         return hiddenBoardIDs[board.id] | ||||
|         return hiddenBoardIDs.includes(board.id) | ||||
|     } | ||||
|  | ||||
|     const showCard = useCallback((cardId?: string) => { | ||||
|   | ||||
| @@ -1033,6 +1033,22 @@ class OctoClient { | ||||
|             body: '{}', | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     async hideBoard(categoryID: string, boardID: string): Promise<Response> { | ||||
|         const path = `${this.teamPath()}/categories/${categoryID}/boards/${boardID}/hide` | ||||
|         return fetch(this.getBaseURL() + path, { | ||||
|             method: 'PUT', | ||||
|             headers: this.headers(), | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     async unhideBoard(categoryID: string, boardID: string): Promise<Response> { | ||||
|         const path = `${this.teamPath()}/categories/${categoryID}/boards/${boardID}/unhide` | ||||
|         return fetch(this.getBaseURL() + path, { | ||||
|             method: 'PUT', | ||||
|             headers: this.headers(), | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| const octoClient = new OctoClient() | ||||
|   | ||||
| @@ -12,7 +12,7 @@ import octoClient from '../../octoClient' | ||||
| import {Subscription, WSClient} from '../../wsclient' | ||||
| import {Utils} from '../../utils' | ||||
| import {useWebsockets} from '../../hooks/websockets' | ||||
| import {IUser, UserConfigPatch} from '../../user' | ||||
| import {IUser} from '../../user' | ||||
| import {Block} from '../../blocks/block' | ||||
| import {ContentBlock} from '../../blocks/contentBlock' | ||||
| import {CommentBlock} from '../../blocks/commentBlock' | ||||
| @@ -40,7 +40,7 @@ import { | ||||
|     fetchUserBlockSubscriptions, | ||||
|     getMe, | ||||
|     followBlock, | ||||
|     unfollowBlock, patchProps, getMyConfig, | ||||
|     unfollowBlock, | ||||
| } from '../../store/users' | ||||
| import {setGlobalError} from '../../store/globalError' | ||||
| import {UserSettings} from '../../userSettings' | ||||
| @@ -52,6 +52,8 @@ import TelemetryClient, {TelemetryActions, TelemetryCategory} from '../../teleme | ||||
|  | ||||
| import {Constants} from '../../constants' | ||||
|  | ||||
| import {getCategoryOfBoard, getHiddenBoardIDs} from '../../store/sidebar' | ||||
|  | ||||
| import SetWindowTitleAndIcon from './setWindowTitleAndIcon' | ||||
| import TeamToBoardAndViewRedirect from './teamToBoardAndViewRedirect' | ||||
| import UndoRedoHotKeys from './undoRedoHotKeys' | ||||
| @@ -75,7 +77,8 @@ const BoardPage = (props: Props): JSX.Element => { | ||||
|     const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId | ||||
|     const viewId = match.params.viewId | ||||
|     const me = useAppSelector<IUser|null>(getMe) | ||||
|     const myConfig = useAppSelector(getMyConfig) | ||||
|     const hiddenBoardIDs = useAppSelector(getHiddenBoardIDs) | ||||
|     const category = useAppSelector(getCategoryOfBoard(activeBoardId)) | ||||
|  | ||||
|     // if we're in a legacy route and not showing a shared board, | ||||
|     // redirect to the new URL schema equivalent | ||||
| @@ -220,28 +223,11 @@ const BoardPage = (props: Props): JSX.Element => { | ||||
|     }, [teamId, match.params.boardId, viewId, me?.id]) | ||||
|  | ||||
|     const handleUnhideBoard = async (boardID: string) => { | ||||
|         Utils.log('handleUnhideBoard called') | ||||
|         if (!me) { | ||||
|         if (!me || !category) { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         const hiddenBoards = {...(myConfig.hiddenBoardIDs ? myConfig.hiddenBoardIDs.value : {})} | ||||
|  | ||||
|         // const index = hiddenBoards.indexOf(boardID) | ||||
|         // hiddenBoards.splice(index, 1) | ||||
|         delete hiddenBoards[boardID] | ||||
|         const hiddenBoardsArray = Object.keys(hiddenBoards) | ||||
|         const patch: UserConfigPatch = { | ||||
|             updatedFields: { | ||||
|                 hiddenBoardIDs: JSON.stringify(hiddenBoardsArray), | ||||
|             }, | ||||
|         } | ||||
|         const patchedProps = await octoClient.patchUserConfig(me.id, patch) | ||||
|         if (!patchedProps) { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         await dispatch(patchProps(patchedProps)) | ||||
|         await octoClient.unhideBoard(category.id, boardID) | ||||
|     } | ||||
|  | ||||
|     useEffect(() => { | ||||
| @@ -249,8 +235,7 @@ const BoardPage = (props: Props): JSX.Element => { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         const hiddenBoardIDs = myConfig.hiddenBoardIDs?.value || {} | ||||
|         if (hiddenBoardIDs[match.params.boardId]) { | ||||
|         if (hiddenBoardIDs.indexOf(match.params.boardId) >= 0) { | ||||
|             handleUnhideBoard(match.params.boardId) | ||||
|         } | ||||
|     }, [me?.id, teamId, match.params.boardId]) | ||||
|   | ||||
| @@ -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 | ||||
|                         } | ||||
|                     } | ||||
|   | ||||
| @@ -25,18 +25,24 @@ interface Category { | ||||
|     isNew: boolean | ||||
| } | ||||
|  | ||||
| interface CategoryBoardMetadata { | ||||
|     boardID: string | ||||
|     hidden: boolean | ||||
| } | ||||
|  | ||||
| interface CategoryBoards extends Category { | ||||
|     boardIDs: string[] | ||||
|     boardMetadata: CategoryBoardMetadata[] | ||||
| } | ||||
|  | ||||
| interface BoardCategoryWebsocketData { | ||||
|     boardID: string | ||||
|     categoryID: string | ||||
|     hidden: boolean | ||||
| } | ||||
|  | ||||
| interface CategoryBoardsReorderData { | ||||
|     categoryID: string | ||||
|     boardIDs: string[] | ||||
|     boardsMetadata: CategoryBoardMetadata[] | ||||
| } | ||||
|  | ||||
| export const DefaultCategory: CategoryBoards = { | ||||
| @@ -53,11 +59,12 @@ export const fetchSidebarCategories = createAsyncThunk( | ||||
|  | ||||
| type Sidebar = { | ||||
|     categoryAttributes: CategoryBoards[] | ||||
|     hiddenBoardIDs: string[] | ||||
| } | ||||
|  | ||||
| const sidebarSlice = createSlice({ | ||||
|     name: 'sidebar', | ||||
|     initialState: {categoryAttributes: []} as Sidebar, | ||||
|     initialState: {categoryAttributes: [], hiddenBoardIDs: []} as Sidebar, | ||||
|     reducers: { | ||||
|         updateCategories: (state, action: PayloadAction<Category[]>) => { | ||||
|             action.payload.forEach((updatedCategory) => { | ||||
| @@ -68,7 +75,7 @@ const sidebarSlice = createSlice({ | ||||
|                     // new categories should always show up on the top | ||||
|                     state.categoryAttributes.unshift({ | ||||
|                         ...updatedCategory, | ||||
|                         boardIDs: [], | ||||
|                         boardMetadata: [], | ||||
|                         isNew: true, | ||||
|                     }) | ||||
|                 } else if (updatedCategory.deleteAt) { | ||||
| @@ -87,31 +94,44 @@ const sidebarSlice = createSlice({ | ||||
|         }, | ||||
|         updateBoardCategories: (state, action: PayloadAction<BoardCategoryWebsocketData[]>) => { | ||||
|             const updatedCategoryAttributes: CategoryBoards[] = [] | ||||
|             let updatedHiddenBoardIDs = state.hiddenBoardIDs | ||||
|  | ||||
|             action.payload.forEach((boardCategory) => { | ||||
|                 for (let i = 0; i < state.categoryAttributes.length; i++) { | ||||
|                     const categoryAttribute = state.categoryAttributes[i] | ||||
|  | ||||
|                     if (categoryAttribute.id === boardCategory.categoryID) { | ||||
|                         // if board is already in the right category, don't do anything | ||||
|                         // and let the board stay in its right order. | ||||
|                         // Only if its not in the right category, do add it. | ||||
|                         if (categoryAttribute.boardIDs.indexOf(boardCategory.boardID) < 0) { | ||||
|                             categoryAttribute.boardIDs.unshift(boardCategory.boardID) | ||||
|                         const categoryBoardMetadataIndex = categoryAttribute.boardMetadata.findIndex((boardMetadata) => boardMetadata.boardID === boardCategory.boardID) | ||||
|                         if (categoryBoardMetadataIndex >= 0) { | ||||
|                             categoryAttribute.boardMetadata[categoryBoardMetadataIndex] = { | ||||
|                                 ...categoryAttribute.boardMetadata[categoryBoardMetadataIndex], | ||||
|                                 hidden: boardCategory.hidden, | ||||
|                             } | ||||
|                         } else { | ||||
|                             categoryAttribute.boardMetadata.unshift({boardID: boardCategory.boardID, hidden: boardCategory.hidden}) | ||||
|                             categoryAttribute.isNew = false | ||||
|                         } | ||||
|                     } else { | ||||
|                         // remove the board from other categories | ||||
|                         categoryAttribute.boardIDs = categoryAttribute.boardIDs.filter((boardID) => boardID !== boardCategory.boardID) | ||||
|                         categoryAttribute.boardMetadata = categoryAttribute.boardMetadata.filter((metadata) => metadata.boardID !== boardCategory.boardID) | ||||
|                     } | ||||
|  | ||||
|                     updatedCategoryAttributes[i] = categoryAttribute | ||||
|  | ||||
|                     if (boardCategory.hidden) { | ||||
|                         if (updatedHiddenBoardIDs.indexOf(boardCategory.boardID) < 0) { | ||||
|                             updatedHiddenBoardIDs.push(boardCategory.boardID) | ||||
|                         } | ||||
|                     } else { | ||||
|                         updatedHiddenBoardIDs = updatedHiddenBoardIDs.filter((hiddenBoardID) => hiddenBoardID !== boardCategory.boardID) | ||||
|                     } | ||||
|                 } | ||||
|             }) | ||||
|  | ||||
|             if (updatedCategoryAttributes.length > 0) { | ||||
|                 state.categoryAttributes = updatedCategoryAttributes | ||||
|             } | ||||
|             state.hiddenBoardIDs = updatedHiddenBoardIDs | ||||
|         }, | ||||
|         updateCategoryOrder: (state, action: PayloadAction<string[]>) => { | ||||
|             if (action.payload.length === 0) { | ||||
| @@ -134,7 +154,7 @@ const sidebarSlice = createSlice({ | ||||
|             state.categoryAttributes = newOrderedCategories | ||||
|         }, | ||||
|         updateCategoryBoardsOrder: (state, action: PayloadAction<CategoryBoardsReorderData>) => { | ||||
|             if (action.payload.boardIDs.length === 0) { | ||||
|             if (action.payload.boardsMetadata.length === 0) { | ||||
|                 return | ||||
|             } | ||||
|  | ||||
| @@ -145,9 +165,9 @@ const sidebarSlice = createSlice({ | ||||
|             } | ||||
|  | ||||
|             const category = state.categoryAttributes[categoryIndex] | ||||
|             const updatedCategory = { | ||||
|             const updatedCategory: CategoryBoards = { | ||||
|                 ...category, | ||||
|                 boardIDs: action.payload.boardIDs, | ||||
|                 boardMetadata: action.payload.boardsMetadata, | ||||
|                 isNew: false, | ||||
|             } | ||||
|  | ||||
| @@ -158,6 +178,17 @@ const sidebarSlice = createSlice({ | ||||
|     extraReducers: (builder) => { | ||||
|         builder.addCase(fetchSidebarCategories.fulfilled, (state, action) => { | ||||
|             state.categoryAttributes = action.payload || [] | ||||
|             state.hiddenBoardIDs = state.categoryAttributes.flatMap( | ||||
|                 (ca) => { | ||||
|                     return ca.boardMetadata.reduce((collector, m) => { | ||||
|                         if (m.hidden) { | ||||
|                             collector.push(m.boardID) | ||||
|                         } | ||||
|  | ||||
|                         return collector | ||||
|                     }, [] as string[]) | ||||
|                 }, | ||||
|             ) | ||||
|         }) | ||||
|     }, | ||||
| }) | ||||
| @@ -167,9 +198,18 @@ export const getSidebarCategories = createSelector( | ||||
|     (sidebarCategories) => sidebarCategories, | ||||
| ) | ||||
|  | ||||
| export const getHiddenBoardIDs = (state: RootState): string[] => state.sidebar.hiddenBoardIDs | ||||
|  | ||||
| export function getCategoryOfBoard(boardID: string): (state: RootState) => CategoryBoards | undefined { | ||||
|     return createSelector( | ||||
|         (state: RootState): CategoryBoards[] => state.sidebar.categoryAttributes, | ||||
|         (sidebarCategories) => sidebarCategories.find((category) => category.boardMetadata.findIndex((m) => m.boardID === boardID) >= 0), | ||||
|     ) | ||||
| } | ||||
|  | ||||
| export const {reducer} = sidebarSlice | ||||
|  | ||||
| export const {updateCategories, updateBoardCategories, updateCategoryOrder, updateCategoryBoardsOrder} = sidebarSlice.actions | ||||
|  | ||||
| export {Category, CategoryBoards, BoardCategoryWebsocketData, CategoryBoardsReorderData} | ||||
| export {Category, CategoryBoards, BoardCategoryWebsocketData, CategoryBoardsReorderData, CategoryBoardMetadata} | ||||
|  | ||||
|   | ||||
| @@ -182,7 +182,7 @@ class TestBlockFactory { | ||||
|     static createCategoryBoards(): CategoryBoards { | ||||
|         return { | ||||
|             ...TestBlockFactory.createCategory(), | ||||
|             boardIDs: [], | ||||
|             boardMetadata: [], | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user