From 9918a0b3f8a249e1fc43afeceddb084edf73a10c Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Thu, 24 Nov 2022 15:31:32 +0530 Subject: [PATCH] DND support for category and boards in LHS (#3964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP * WIP * Removed unused webapp util * Added server tests * Lint fix * Updating existing tests * Updating existing tests * Updating existing tests * Fixing existing tests * Fixing existing tests * Fixing existing tests * WIP * Added category sort order migration * Added logic to set new category on top * Implemented api, WS listein logic remining * finished webapp implementation * Added category type and tests * updated tests * Fixed integration test * type fix * WIP * implemented boards DND to other category and in same category * removed seconds from boards name * wip * debugging cy test * Enabled hiding views list while DNDing * Removed some debug logs * Fixed a bug preventing users from collapsing boards category * WIP * Debugging cypress test * CI * debugging cy test * Testing a fix * reverting test fix * Handled personal server * WIP * WIP * Adding support for building with esbuild * Using different index.html templates for esbuild * WIP * WIP * Fixed delete category and rename category * WIP * WIP * Finally, its done. * Adde suppor tot update board-category mapping in bulk * Fixed a bug where create category option didn't show up on default category * Fixed bug where new board was added as last board in Boards category instead of first board * Minor cleanup * WIP * Added support to drab boards onto collapsed categories * Fixed route order from specific to generic * Fix linter * Updated existin server tests * fixed integration tests * Fixed webapp test err * Removed accidental dependencies * Adding new server tests * Finished server tests * added api to client.go * Added API integration test * Fixed existing webapp tests * WIP * WIP * WIP * WIP * WIP * Fixed missing paranthesis * Some cleanup * fixed server lint * noopped down migration * Fixed issue with DND not working great with newly added category * Fixed a test * Fixed a test * Fixed a test * Fixed console error while DNDing * pakg lock restore * Fixed missing react beautiful dnd in package.lock.json * updated snapshots * Fixed webapp test * Review fixes * Added API permission check Co-authored-by: Jesús Espino --- mattermost-plugin/webapp/src/index.tsx | 3 + server/api/categories.go | 161 ++- server/app/boards.go | 11 +- server/app/boards_test.go | 64 +- server/app/category.go | 124 +- server/app/category_boards.go | 97 +- server/app/category_boards_test.go | 57 +- server/app/category_test.go | 203 +++ server/app/import_test.go | 2 +- server/app/onboarding_test.go | 4 +- server/client/client.go | 33 + server/integrationtests/clienttestlib.go | 22 + server/integrationtests/sidebar_test.go | 54 + server/model/category.go | 29 +- server/model/category_boards.go | 6 + server/services/store/mockstore/mockstore.go | 53 +- server/services/store/sqlstore/category.go | 77 +- .../store/sqlstore/category_boards.go | 101 +- .../000030_add_category_sort_order.down.sql | 1 + .../000030_add_category_sort_order.up.sql | 1 + ...31_add_category_boards_sort_order.down.sql | 1 + ...0031_add_category_boards_sort_order.up.sql | 1 + ...00032_move_boards_category_to_end.down.sql | 1 + .../000032_move_boards_category_to_end.up.sql | 15 + .../services/store/sqlstore/public_methods.go | 42 +- .../services/store/sqlstore/sqlstore_test.go | 3 +- server/services/store/store.go | 7 +- server/services/store/storetests/category.go | 164 ++- .../store/storetests/categoryBoards.go | 6 +- .../store/storetests/data_retention.go | 2 +- server/ws/adapter.go | 6 +- server/ws/common.go | 21 +- server/ws/plugin_adapter.go | 57 +- server/ws/server.go | 71 +- webapp/i18n/en.json | 2 + webapp/package-lock.json | 92 ++ webapp/package.json | 2 + .../__snapshots__/workspace.test.tsx.snap | 370 ++--- .../__snapshots__/sidebar.test.tsx.snap | 808 +++++++---- .../sidebarBoardItem.test.tsx.snap | 1026 +++++++------- .../sidebarCategory.test.tsx.snap | 1245 ++++++++++------- .../src/components/sidebar/sidebar.test.tsx | 3 + webapp/src/components/sidebar/sidebar.tsx | 204 ++- .../sidebar/sidebarBoardItem.test.tsx | 18 +- .../components/sidebar/sidebarBoardItem.tsx | 212 +-- .../components/sidebar/sidebarCategory.scss | 48 + .../sidebar/sidebarCategory.test.tsx | 30 +- .../components/sidebar/sidebarCategory.tsx | 365 +++-- webapp/src/components/workspace.scss | 2 +- webapp/src/octoClient.ts | 32 + webapp/src/store/cards.ts | 4 +- webapp/src/store/sidebar.ts | 102 +- webapp/src/styles/main.scss | 2 +- webapp/src/test/testBlockFactory.ts | 2 + webapp/src/testUtils.tsx | 27 + webapp/src/utils.ts | 5 +- webapp/src/widgets/icons/HandRight.tsx | 15 + webapp/src/wsclient.ts | 42 +- 58 files changed, 4272 insertions(+), 1886 deletions(-) create mode 100644 server/integrationtests/sidebar_test.go create mode 100644 server/services/store/sqlstore/migrations/000030_add_category_sort_order.down.sql create mode 100644 server/services/store/sqlstore/migrations/000030_add_category_sort_order.up.sql create mode 100644 server/services/store/sqlstore/migrations/000031_add_category_boards_sort_order.down.sql create mode 100644 server/services/store/sqlstore/migrations/000031_add_category_boards_sort_order.up.sql create mode 100644 server/services/store/sqlstore/migrations/000032_move_boards_category_to_end.down.sql create mode 100644 server/services/store/sqlstore/migrations/000032_move_boards_category_to_end.up.sql create mode 100644 webapp/src/widgets/icons/HandRight.tsx diff --git a/mattermost-plugin/webapp/src/index.tsx b/mattermost-plugin/webapp/src/index.tsx index 0b25a8480..b883bb056 100644 --- a/mattermost-plugin/webapp/src/index.tsx +++ b/mattermost-plugin/webapp/src/index.tsx @@ -55,6 +55,7 @@ import wsClient, { ACTION_UPDATE_CATEGORY, ACTION_UPDATE_BOARD_CATEGORY, ACTION_UPDATE_BOARD, + ACTION_REORDER_CATEGORIES, } from './../../../webapp/src/wsclient' import manifest from './manifest' @@ -209,6 +210,8 @@ export default class Plugin { this.registry?.registerWebSocketEventHandler(`custom_${productID}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data)) this.registry?.registerWebSocketEventHandler(`custom_${productID}_${ACTION_UPDATE_CARD_LIMIT_TIMESTAMP}`, (e: any) => wsClient.updateCardLimitTimestampHandler(e.data)) this.registry?.registerWebSocketEventHandler(`custom_${productID}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data)) + this.registry?.registerWebSocketEventHandler(`custom_${productID}_${ACTION_REORDER_CATEGORIES}`, (e) => wsClient.updateHandler(e.data)) + this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data)) this.registry?.registerPostTypeComponent('custom_cloud_upgrade_nudge', CloudUpgradeNudge) this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => { diff --git a/server/api/categories.go b/server/api/categories.go index 369b94e40..9181c42d5 100644 --- a/server/api/categories.go +++ b/server/api/categories.go @@ -14,9 +14,11 @@ import ( func (a *API) registerCategoriesRoutes(r *mux.Router) { // Category APIs r.HandleFunc("/teams/{teamID}/categories", a.sessionRequired(a.handleCreateCategory)).Methods(http.MethodPost) + r.HandleFunc("/teams/{teamID}/categories/reorder", a.sessionRequired(a.handleReorderCategories)).Methods(http.MethodPut) r.HandleFunc("/teams/{teamID}/categories/{categoryID}", a.sessionRequired(a.handleUpdateCategory)).Methods(http.MethodPut) r.HandleFunc("/teams/{teamID}/categories/{categoryID}", a.sessionRequired(a.handleDeleteCategory)).Methods(http.MethodDelete) 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) } @@ -353,7 +355,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, categoryID, boardID) + err := a.app.AddUpdateUserCategoryBoard(teamID, userID, map[string]string{boardID: categoryID}) if err != nil { a.errorResponse(w, r, err) return @@ -362,3 +364,160 @@ func (a *API) handleUpdateCategoryBoard(w http.ResponseWriter, r *http.Request) jsonBytesResponse(w, http.StatusOK, []byte("ok")) auditRec.Success() } + +func (a *API) handleReorderCategories(w http.ResponseWriter, r *http.Request) { + // swagger:operation PUT /teams/{teamID}/categories/reorder handleReorderCategories + // + // Updated sidebar category order + // + // --- + // produces: + // - application/json + // parameters: + // - name: teamID + // in: path + // description: Team ID + // required: true + // type: string + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + + vars := mux.Vars(r) + teamID := vars["teamID"] + + ctx := r.Context() + session := ctx.Value(sessionContextKey).(*model.Session) + userID := session.UserID + + if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { + a.errorResponse(w, r, model.NewErrPermission("access denied to category")) + return + } + + requestBody, err := io.ReadAll(r.Body) + if err != nil { + a.errorResponse(w, r, err) + return + } + + var newCategoryOrder []string + + err = json.Unmarshal(requestBody, &newCategoryOrder) + if err != nil { + a.errorResponse(w, r, err) + return + } + + auditRec := a.makeAuditRecord(r, "reorderCategories", audit.Fail) + defer a.audit.LogRecord(audit.LevelModify, auditRec) + + auditRec.AddMeta("TeamID", teamID) + auditRec.AddMeta("CategoryCount", len(newCategoryOrder)) + + updatedCategoryOrder, err := a.app.ReorderCategories(userID, teamID, newCategoryOrder) + if err != nil { + a.errorResponse(w, r, err) + return + } + + data, err := json.Marshal(updatedCategoryOrder) + if err != nil { + a.errorResponse(w, r, err) + return + } + + jsonBytesResponse(w, http.StatusOK, data) + auditRec.Success() +} + +func (a *API) handleReorderCategoryBoards(w http.ResponseWriter, r *http.Request) { + // swagger:operation PUT /teams/{teamID}/categories/{categoryID}/boards/reorder handleReorderCategoryBoards + // + // Updates order of boards inside a sidebar category + // + // --- + // produces: + // - application/json + // parameters: + // - name: teamID + // in: path + // description: Team ID + // required: true + // type: string + // - name: categoryID + // in: path + // description: Category ID + // required: true + // type: string + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + + vars := mux.Vars(r) + teamID := vars["teamID"] + categoryID := vars["categoryID"] + + ctx := r.Context() + session := ctx.Value(sessionContextKey).(*model.Session) + userID := session.UserID + + if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { + a.errorResponse(w, r, model.NewErrPermission("access denied to category")) + return + } + + category, err := a.app.GetCategory(categoryID) + if err != nil { + a.errorResponse(w, r, err) + return + } + + if category.UserID != userID { + a.errorResponse(w, r, model.NewErrPermission("access denied to category")) + return + } + + requestBody, err := io.ReadAll(r.Body) + if err != nil { + a.errorResponse(w, r, err) + return + } + + var newBoardsOrder []string + err = json.Unmarshal(requestBody, &newBoardsOrder) + if err != nil { + a.errorResponse(w, r, err) + return + } + + auditRec := a.makeAuditRecord(r, "reorderCategoryBoards", audit.Fail) + defer a.audit.LogRecord(audit.LevelModify, auditRec) + + updatedBoardsOrder, err := a.app.ReorderCategoryBoards(userID, teamID, categoryID, newBoardsOrder) + if err != nil { + a.errorResponse(w, r, err) + return + } + + data, err := json.Marshal(updatedBoardsOrder) + if err != nil { + a.errorResponse(w, r, err) + return + } + + jsonBytesResponse(w, http.StatusOK, data) + auditRec.Success() +} diff --git a/server/app/boards.go b/server/app/boards.go index c65720ea6..533c74320 100644 --- a/server/app/boards.go +++ b/server/app/boards.go @@ -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, destinationCategoryID, destinationBoardID) + return a.AddUpdateUserCategoryBoard(teamID, userID, map[string]string{destinationBoardID: destinationCategoryID}) } func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) { @@ -327,10 +327,13 @@ 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 { - if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, board.ID); err != nil { - return err - } + boardCategoryMapping[board.ID] = defaultCategoryID + } + + if err := a.AddUpdateUserCategoryBoard(teamID, userID, boardCategoryMapping); err != nil { + return err } return nil diff --git a/server/app/boards_test.go b/server/app/boards_test.go index 5ca96fcf4..0dfe194cb 100644 --- a/server/app/boards_test.go +++ b/server/app/boards_test.go @@ -52,7 +52,7 @@ func TestAddMemberToBoard(t *testing.T) { }, }, }, nil) - th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", "board_id_1").Return(nil) + th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "default_category_id"}).Return(nil) addedBoardMember, err := th.App.AddMemberToBoard(boardMember) require.NoError(t, err) @@ -126,7 +126,7 @@ func TestAddMemberToBoard(t *testing.T) { }, }, }, nil) - th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", "board_id_1").Return(nil) + th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "default_category_id"}).Return(nil) addedBoardMember, err := th.App.AddMemberToBoard(boardMember) require.NoError(t, err) @@ -434,9 +434,11 @@ func TestBoardCategory(t *testing.T) { Name: "Boards", }, nil) th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil) - th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_1").Return(nil) - th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_2").Return(nil) - th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_3").Return(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) boards := []*model.Board{ {ID: "board_id_1"}, @@ -449,3 +451,55 @@ func TestBoardCategory(t *testing.T) { }) }) } + +func TestDuplicateBoard(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + t.Run("base case", func(t *testing.T) { + board := &model.Board{ + ID: "board_id_2", + Title: "Duplicated Board", + } + + block := &model.Block{ + ID: "block_id_1", + Type: "image", + } + + th.Store.EXPECT().DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false).Return( + &model.BoardsAndBlocks{ + Boards: []*model.Board{ + board, + }, + Blocks: []*model.Block{ + block, + }, + }, + []*model.BoardMember{}, + nil, + ) + + th.Store.EXPECT().GetBoard("board_id_1").Return(&model.Board{}, nil) + + th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{ + { + Category: model.Category{ + ID: "category_id_1", + Name: "Boards", + Type: "system", + }, + }, + }, nil).Times(2) + + th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", utils.Anything).Return(nil) + + // for WS change broadcast + th.Store.EXPECT().GetMembersForBoard(utils.Anything).Return([]*model.BoardMember{}, nil).Times(2) + + bab, members, err := th.App.DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false) + assert.NoError(t, err) + assert.NotNil(t, bab) + assert.NotNil(t, members) + }) +} diff --git a/server/app/category.go b/server/app/category.go index 032ded73b..6fbd0bb10 100644 --- a/server/app/category.go +++ b/server/app/category.go @@ -2,15 +2,21 @@ package app import ( "errors" - "strings" + "fmt" "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/utils" ) +var errCategoryNotFound = errors.New("category ID specified in input does not exist for user") +var errCategoriesLengthMismatch = errors.New("cannot update category order, passed list of categories different size than in database") var ErrCannotDeleteSystemCategory = errors.New("cannot delete a system category") var ErrCannotUpdateSystemCategory = errors.New("cannot update a system category") +func (a *App) GetCategory(categoryID string) (*model.Category, error) { + return a.store.GetCategory(categoryID) +} + func (a *App) CreateCategory(category *model.Category) (*model.Category, error) { category.Hydrate() if err := category.IsValid(); err != nil { @@ -34,10 +40,8 @@ func (a *App) CreateCategory(category *model.Category) (*model.Category, error) } func (a *App) UpdateCategory(category *model.Category) (*model.Category, error) { - // set to default category, UI doesn't create with Type - if strings.TrimSpace(category.Type) == "" { - category.Type = model.CategoryTypeCustom - } + category.Hydrate() + if err := category.IsValid(); err != nil { return nil, err } @@ -115,6 +119,10 @@ func (a *App) DeleteCategory(categoryID, userID, teamID string) (*model.Category return nil, ErrCannotDeleteSystemCategory } + if err = a.moveBoardsToDefaultCategory(userID, teamID, categoryID); err != nil { + return nil, err + } + if err = a.store.DeleteCategory(categoryID, userID, teamID); err != nil { return nil, err } @@ -130,3 +138,109 @@ func (a *App) DeleteCategory(categoryID, userID, teamID string) (*model.Category return deletedCategory, nil } + +func (a *App) moveBoardsToDefaultCategory(userID, teamID, sourceCategoryID string) error { + // we need a list of boards associated to this category + // so we can move them to user's default Boards category + categoryBoards, err := a.GetUserCategoryBoards(userID, teamID) + if err != nil { + return err + } + + var sourceCategoryBoards *model.CategoryBoards + defaultCategoryID := "" + + // iterate user's categories to find the source category + // and the default category. + // We need source category to get the list of its board + // and the default category to know its ID to + // move source category's boards to. + for i := range categoryBoards { + if categoryBoards[i].ID == sourceCategoryID { + sourceCategoryBoards = &categoryBoards[i] + } + + if categoryBoards[i].Name == defaultCategoryBoards { + defaultCategoryID = categoryBoards[i].ID + } + + // if both categories are found, no need to iterate furthur. + if sourceCategoryBoards != nil && defaultCategoryID != "" { + break + } + } + + if sourceCategoryBoards == nil { + return errCategoryNotFound + } + + if defaultCategoryID == "" { + return fmt.Errorf("moveBoardsToDefaultCategory: %w", errNoDefaultCategoryFound) + } + + boardCategoryMapping := map[string]string{} + + for _, boardID := range sourceCategoryBoards.BoardIDs { + boardCategoryMapping[boardID] = defaultCategoryID + } + + if err := a.AddUpdateUserCategoryBoard(teamID, userID, boardCategoryMapping); err != nil { + return fmt.Errorf("moveBoardsToDefaultCategory: %w", err) + } + + return nil +} + +func (a *App) ReorderCategories(userID, teamID string, newCategoryOrder []string) ([]string, error) { + if err := a.verifyNewCategoriesMatchExisting(userID, teamID, newCategoryOrder); err != nil { + return nil, err + } + + newOrder, err := a.store.ReorderCategories(userID, teamID, newCategoryOrder) + if err != nil { + return nil, err + } + + go func() { + a.wsAdapter.BroadcastCategoryReorder(teamID, userID, newOrder) + }() + + return newOrder, nil +} + +func (a *App) verifyNewCategoriesMatchExisting(userID, teamID string, newCategoryOrder []string) error { + existingCategories, err := a.store.GetUserCategories(userID, teamID) + if err != nil { + return err + } + + if len(newCategoryOrder) != len(existingCategories) { + return fmt.Errorf( + "%w length new categories: %d, length existing categories: %d, userID: %s, teamID: %s", + errCategoriesLengthMismatch, + len(newCategoryOrder), + len(existingCategories), + userID, + teamID, + ) + } + + existingCategoriesMap := map[string]bool{} + for _, category := range existingCategories { + existingCategoriesMap[category.ID] = true + } + + for _, newCategoryID := range newCategoryOrder { + if _, found := existingCategoriesMap[newCategoryID]; !found { + return fmt.Errorf( + "%w specified category ID: %s, userID: %s, teamID: %s", + errCategoryNotFound, + newCategoryID, + userID, + teamID, + ) + } + } + + return nil +} diff --git a/server/app/category_boards.go b/server/app/category_boards.go index 582cdba4f..8b92f676c 100644 --- a/server/app/category_boards.go +++ b/server/app/category_boards.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "github.com/mattermost/focalboard/server/model" @@ -8,6 +9,9 @@ import ( const defaultCategoryBoards = "Boards" +var errCategoryBoardsLengthMismatch = errors.New("cannot update category boards order, passed list of categories boards different size than in database") +var errBoardNotFoundInCategory = errors.New("specified board ID not found in specified category ID") + func (a *App) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) { categoryBoards, err := a.store.GetUserCategoryBoards(userID, teamID) if err != nil { @@ -53,6 +57,7 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards TeamID: teamID, Collapsed: false, Type: model.CategoryTypeSystem, + SortOrder: len(existingCategoryBoards) * model.CategoryBoardsSortOrderGap, } createdCategory, err := a.CreateCategory(&category) if err != nil { @@ -91,7 +96,7 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards } if !belongsToCategory { - if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, board.ID); err != nil { + 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) } @@ -102,22 +107,100 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards return createdCategoryBoards, nil } -func (a *App) AddUpdateUserCategoryBoard(teamID, userID, categoryID, boardID string) error { - err := a.store.AddUpdateCategoryBoard(userID, categoryID, boardID) +func (a *App) AddUpdateUserCategoryBoard(teamID, userID string, boardCategoryMapping map[string]string) error { + err := a.store.AddUpdateCategoryBoard(userID, boardCategoryMapping) if err != nil { return err } + wsPayload := make([]*model.BoardCategoryWebsocketData, len(boardCategoryMapping)) + i := 0 + for boardID, categoryID := range boardCategoryMapping { + wsPayload[i] = &model.BoardCategoryWebsocketData{ + BoardID: boardID, + CategoryID: categoryID, + } + i++ + } + a.blockChangeNotifier.Enqueue(func() error { a.wsAdapter.BroadcastCategoryBoardChange( teamID, userID, - model.BoardCategoryWebsocketData{ - BoardID: boardID, - CategoryID: categoryID, - }) + wsPayload, + ) return nil }) return nil } + +func (a *App) ReorderCategoryBoards(userID, teamID, categoryID string, newBoardsOrder []string) ([]string, error) { + if err := a.verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID, newBoardsOrder); err != nil { + return nil, err + } + + newOrder, err := a.store.ReorderCategoryBoards(categoryID, newBoardsOrder) + if err != nil { + return nil, err + } + + go func() { + a.wsAdapter.BroadcastCategoryBoardsReorder(teamID, userID, categoryID, newOrder) + }() + + return newOrder, nil +} + +func (a *App) verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID string, newBoardsOrder []string) error { + // this function is to ensure that we don't miss specifying + // all boards of the category while reordering. + existingCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID) + if err != nil { + return err + } + + var targetCategoryBoards *model.CategoryBoards + for i := range existingCategoryBoards { + if existingCategoryBoards[i].Category.ID == categoryID { + targetCategoryBoards = &existingCategoryBoards[i] + break + } + } + + if targetCategoryBoards == nil { + return fmt.Errorf("%w categoryID: %s", errCategoryNotFound, categoryID) + } + + if len(targetCategoryBoards.BoardIDs) != 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), + userID, + teamID, + categoryID, + ) + } + + existingBoardMap := map[string]bool{} + for _, boardID := range targetCategoryBoards.BoardIDs { + existingBoardMap[boardID] = true + } + + for _, boardID := range newBoardsOrder { + if _, found := existingBoardMap[boardID]; !found { + return fmt.Errorf( + "%w board ID: %s, category ID: %s, userID: %s, teamID: %s", + errBoardNotFoundInCategory, + boardID, + categoryID, + userID, + teamID, + ) + } + } + + return nil +} diff --git a/server/app/category_boards_test.go b/server/app/category_boards_test.go index 633ef6499..6f92dc339 100644 --- a/server/app/category_boards_test.go +++ b/server/app/category_boards_test.go @@ -34,9 +34,9 @@ func TestGetUserCategoryBoards(t *testing.T) { } th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil) - th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_1").Return(nil) - th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_2").Return(nil) - th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_3").Return(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) categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id") assert.NoError(t, err) @@ -80,3 +80,54 @@ func TestGetUserCategoryBoards(t *testing.T) { assert.Equal(t, 2, len(categoryBoards[0].BoardIDs)) }) } + +func TestReorderCategoryBoards(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + t.Run("base case", 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"}, + }, + { + Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"}, + BoardIDs: []string{"board_id_3"}, + }, + { + Category: model.Category{ID: "category_id_3", Name: "Category 3"}, + BoardIDs: []string{}, + }, + }, nil) + + th.Store.EXPECT().ReorderCategoryBoards("category_id_1", []string{"board_id_2", "board_id_1"}).Return([]string{"board_id_2", "board_id_1"}, nil) + + newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"}) + assert.NoError(t, err) + assert.Equal(t, 2, len(newOrder)) + assert.Equal(t, "board_id_2", newOrder[0]) + assert.Equal(t, "board_id_1", newOrder[1]) + }) + + t.Run("not specifying all boards", 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", "board_id_3"}, + }, + { + Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"}, + BoardIDs: []string{"board_id_3"}, + }, + { + Category: model.Category{ID: "category_id_3", Name: "Category 3"}, + BoardIDs: []string{}, + }, + }, nil) + + newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"}) + assert.Error(t, err) + assert.Nil(t, newOrder) + }) +} diff --git a/server/app/category_test.go b/server/app/category_test.go index 191b18363..5e4dd349d 100644 --- a/server/app/category_test.go +++ b/server/app/category_test.go @@ -92,6 +92,14 @@ func TestUpdateCategory(t *testing.T) { }) t.Run("updating invalid category", func(t *testing.T) { + th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ + ID: "category_id_1", + Name: "Category", + TeamID: "team_id_1", + UserID: "user_id_1", + Type: "custom", + }, nil) + category := &model.Category{ ID: "category_id_1", Name: "Name", @@ -260,6 +268,33 @@ func TestDeleteCategory(t *testing.T) { DeleteAt: 10000, }, nil) + th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{ + { + Category: model.Category{ + ID: "category_id_default", + DeleteAt: 0, + UserID: "user_id_1", + TeamID: "team_id_1", + Type: "default", + Name: "Boards", + }, + BoardIDs: []string{}, + }, + { + Category: model.Category{ + ID: "category_id_1", + DeleteAt: 0, + UserID: "user_id_1", + TeamID: "team_id_1", + Type: "custom", + Name: "Category 1", + }, + BoardIDs: []string{}, + }, + }, 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) @@ -293,3 +328,171 @@ func TestDeleteCategory(t *testing.T) { assert.Error(t, err) }) } + +func TestMoveBoardsToDefaultCategory(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + t.Run("When default category already exists", func(t *testing.T) { + th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ + { + Category: model.Category{ + ID: "category_id_1", + Name: "Boards", + Type: "system", + }, + }, + { + Category: model.Category{ + ID: "category_id_2", + Name: "Custom Category 1", + Type: "custom", + }, + }, + }, 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) + }) + + t.Run("When default category doesn't already exists", func(t *testing.T) { + th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{ + { + Category: model.Category{ + ID: "category_id_2", + Name: "Custom Category 1", + Type: "custom", + }, + }, + }, nil) + + th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil) + th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{ + ID: "default_category_id", + Name: "Boards", + Type: "system", + }, 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) + }) +} + +func TestReorderCategories(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + t.Run("base case", func(t *testing.T) { + th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{ + { + ID: "category_id_1", + Name: "Boards", + Type: "system", + }, + { + ID: "category_id_2", + Name: "Category 2", + Type: "custom", + }, + { + ID: "category_id_3", + Name: "Category 3", + Type: "custom", + }, + }, nil) + + th.Store.EXPECT().ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"}). + Return([]string{"category_id_2", "category_id_3", "category_id_1"}, nil) + + newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"}) + assert.NoError(t, err) + assert.Equal(t, 3, len(newOrder)) + }) + + t.Run("not specifying all categories should fail", func(t *testing.T) { + th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{ + { + ID: "category_id_1", + Name: "Boards", + Type: "system", + }, + { + ID: "category_id_2", + Name: "Category 2", + Type: "custom", + }, + { + ID: "category_id_3", + Name: "Category 3", + Type: "custom", + }, + }, nil) + + newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3"}) + assert.Error(t, err) + assert.Nil(t, newOrder) + }) +} + +func TestVerifyNewCategoriesMatchExisting(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + t.Run("base case", func(t *testing.T) { + th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{ + { + ID: "category_id_1", + Name: "Boards", + Type: "system", + }, + { + ID: "category_id_2", + Name: "Category 2", + Type: "custom", + }, + { + ID: "category_id_3", + Name: "Category 3", + Type: "custom", + }, + }, nil) + + err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{ + "category_id_2", + "category_id_3", + "category_id_1", + }) + assert.NoError(t, err) + }) + + t.Run("different category counts", func(t *testing.T) { + th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{ + { + ID: "category_id_1", + Name: "Boards", + Type: "system", + }, + { + ID: "category_id_2", + Name: "Category 2", + Type: "custom", + }, + { + ID: "category_id_3", + Name: "Category 3", + Type: "custom", + }, + }, nil) + + err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{ + "category_id_2", + "category_id_3", + }) + assert.Error(t, err) + }) +} diff --git a/server/app/import_test.go b/server/app/import_test.go index ab37e28a8..e69bc40e5 100644 --- a/server/app/import_test.go +++ b/server/app/import_test.go @@ -56,7 +56,7 @@ func TestApp_ImportArchive(t *testing.T) { Name: "Boards", }, nil) th.Store.EXPECT().GetBoardsForUserAndTeam("user", "test-team", false).Return([]*model.Board{}, nil) - th.Store.EXPECT().AddUpdateCategoryBoard("user", "boards_category_id", utils.Anything).Return(nil) + th.Store.EXPECT().AddUpdateCategoryBoard("user", utils.Anything).Return(nil) err := th.App.ImportArchive(r, opts) require.NoError(t, err, "import archive should not fail") diff --git a/server/app/onboarding_test.go b/server/app/onboarding_test.go index 5c1321c2a..f9501e066 100644 --- a/server/app/onboarding_test.go +++ b/server/app/onboarding_test.go @@ -77,7 +77,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", "boards_category_id", "board_id_2").Return(nil) + th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_2": "boards_category_id"}).Return(nil) teamID, boardID, err := th.App.PrepareOnboardingTour(userID, teamID) assert.NoError(t, err) @@ -120,7 +120,7 @@ func TestCreateWelcomeBoard(t *testing.T) { Category: model.Category{ID: "boards_category_id", Name: "Boards"}, }, }, nil).Times(2) - th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", "board_id_1").Return(nil) + th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "boards_category_id"}).Return(nil) boardID, err := th.App.createWelcomeBoard(userID, teamID) assert.Nil(t, err) diff --git a/server/client/client.go b/server/client/client.go index 6ee0613f0..4bc4e08af 100644 --- a/server/client/client.go +++ b/server/client/client.go @@ -442,6 +442,15 @@ func (c *Client) CreateCategory(category model.Category) (*model.Category, *Resp return model.CategoryFromJSON(r.Body), BuildResponse(r) } +func (c *Client) DeleteCategory(teamID, categoryID string) *Response { + r, err := c.DoAPIDelete(c.GetTeamRoute(teamID)+"/categories/"+categoryID, "") + if err != nil { + return BuildErrorResponse(r, err) + } + + return BuildResponse(r) +} + func (c *Client) UpdateCategoryBoard(teamID, categoryID, boardID string) *Response { r, err := c.DoAPIPost(fmt.Sprintf("%s/categories/%s/boards/%s", c.GetTeamRoute(teamID), categoryID, boardID), "") if err != nil { @@ -464,6 +473,30 @@ func (c *Client) GetUserCategoryBoards(teamID string) ([]model.CategoryBoards, * return categoryBoards, BuildResponse(r) } +func (c *Client) ReorderCategories(teamID string, newOrder []string) ([]string, *Response) { + r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/reorder", toJSON(newOrder)) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + + var updatedCategoryOrder []string + _ = json.NewDecoder(r.Body).Decode(&updatedCategoryOrder) + return updatedCategoryOrder, BuildResponse(r) +} + +func (c *Client) ReorderCategoryBoards(teamID, categoryID string, newOrder []string) ([]string, *Response) { + r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/"+categoryID+"/reorder", toJSON(newOrder)) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + + var updatedBoardsOrder []string + _ = json.NewDecoder(r.Body).Decode(&updatedBoardsOrder) + return updatedBoardsOrder, BuildResponse(r) +} + func (c *Client) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks) (*model.BoardsAndBlocks, *Response) { r, err := c.DoAPIPatch(c.GetBoardsAndBlocksRoute(), toJSON(pbab)) if err != nil { diff --git a/server/integrationtests/clienttestlib.go b/server/integrationtests/clienttestlib.go index a26f62b73..46607e38c 100644 --- a/server/integrationtests/clienttestlib.go +++ b/server/integrationtests/clienttestlib.go @@ -457,6 +457,17 @@ func (th *TestHelper) CreateBoard(teamID string, boardType model.BoardType) *mod return board } +func (th *TestHelper) CreateCategory(category model.Category) *model.Category { + cat, resp := th.Client.CreateCategory(category) + th.CheckOK(resp) + return cat +} + +func (th *TestHelper) UpdateCategoryBoard(teamID, categoryID, boardID string) { + response := th.Client.UpdateCategoryBoard(teamID, categoryID, boardID) + th.CheckOK(response) +} + func (th *TestHelper) CreateBoardAndCards(teamdID string, boardType model.BoardType, numCards int) (*model.Board, []*model.Card) { board := th.CreateBoard(teamdID, boardType) cards := make([]*model.Card, 0, numCards) @@ -482,6 +493,17 @@ func (th *TestHelper) MakeCardProps(count int) map[string]any { return props } +func (th *TestHelper) GetUserCategoryBoards(teamID string) []model.CategoryBoards { + categoryBoards, response := th.Client.GetUserCategoryBoards(teamID) + th.CheckOK(response) + return categoryBoards +} + +func (th *TestHelper) DeleteCategory(teamID, categoryID string) { + response := th.Client.DeleteCategory(teamID, categoryID) + th.CheckOK(response) +} + func (th *TestHelper) GetUser1() *model.User { return th.Me(th.Client) } diff --git a/server/integrationtests/sidebar_test.go b/server/integrationtests/sidebar_test.go new file mode 100644 index 000000000..976f1aa9c --- /dev/null +++ b/server/integrationtests/sidebar_test.go @@ -0,0 +1,54 @@ +package integrationtests + +import ( + "testing" + + "github.com/mattermost/focalboard/server/model" + "github.com/stretchr/testify/require" +) + +func TestSidebar(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 + board := th.CreateBoard("team-id", "O") + + 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]) + + // create a new category, a new board + // and move that board into the new category + board2 := th.CreateBoard("team-id", "O") + category := th.CreateCategory(model.Category{ + Name: "Category 2", + TeamID: "team-id", + UserID: "single-user", + }) + th.UpdateCategoryBoard("team-id", category.ID, board2.ID) + + categoryBoards = th.GetUserCategoryBoards("team-id") + // now there should be two categories - boards and the one + // we created just now + require.Equal(t, 2, len(categoryBoards)) + + // 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]) + + // now we'll delete the custom category we created, "Category 2" + // and all it's boards should get moved to the Boards category + th.DeleteCategory("team-id", category.ID) + 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) +} diff --git a/server/model/category.go b/server/model/category.go index 73c372e13..0a3dd903d 100644 --- a/server/model/category.go +++ b/server/model/category.go @@ -49,16 +49,37 @@ type Category struct { // required: true Collapsed bool `json:"collapsed"` + // Inter-category sort order per user + // required: true + SortOrder int `json:"sortOrder"` + + // The sorting method applied on this category + // required: true + Sorting string `json:"sorting"` + // Category's type // required: true Type string `json:"type"` } func (c *Category) Hydrate() { - c.ID = utils.NewID(utils.IDTypeNone) - c.CreateAt = utils.GetMillis() - c.UpdateAt = c.CreateAt - if c.Type == "" { + if c.ID == "" { + c.ID = utils.NewID(utils.IDTypeNone) + } + + if c.CreateAt == 0 { + c.CreateAt = utils.GetMillis() + } + + if c.UpdateAt == 0 { + c.UpdateAt = c.CreateAt + } + + if c.SortOrder < 0 { + c.SortOrder = 0 + } + + if strings.TrimSpace(c.Type) == "" { c.Type = CategoryTypeCustom } } diff --git a/server/model/category_boards.go b/server/model/category_boards.go index 6f0fe5cfd..81cd568a5 100644 --- a/server/model/category_boards.go +++ b/server/model/category_boards.go @@ -1,5 +1,7 @@ package model +const CategoryBoardsSortOrderGap = 10 + // CategoryBoards is a board category and associated boards // swagger:model type CategoryBoards struct { @@ -8,6 +10,10 @@ type CategoryBoards struct { // The IDs of boards in this category // required: true BoardIDs []string `json:"boardIDs"` + + // The relative sort order of this board in its category + // required: true + SortOrder int `json:"sortOrder"` } type BoardCategoryWebsocketData struct { diff --git a/server/services/store/mockstore/mockstore.go b/server/services/store/mockstore/mockstore.go index 0b9174ab6..8ddc03315 100644 --- a/server/services/store/mockstore/mockstore.go +++ b/server/services/store/mockstore/mockstore.go @@ -37,17 +37,17 @@ func (m *MockStore) EXPECT() *MockStoreMockRecorder { } // AddUpdateCategoryBoard mocks base method. -func (m *MockStore) AddUpdateCategoryBoard(arg0, arg1, arg2 string) error { +func (m *MockStore) AddUpdateCategoryBoard(arg0 string, arg1 map[string]string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AddUpdateCategoryBoard", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "AddUpdateCategoryBoard", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } // AddUpdateCategoryBoard indicates an expected call of AddUpdateCategoryBoard. -func (mr *MockStoreMockRecorder) AddUpdateCategoryBoard(arg0, arg1, arg2 interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) AddUpdateCategoryBoard(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUpdateCategoryBoard", reflect.TypeOf((*MockStore)(nil).AddUpdateCategoryBoard), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUpdateCategoryBoard", reflect.TypeOf((*MockStore)(nil).AddUpdateCategoryBoard), arg0, arg1) } // CanSeeUser mocks base method. @@ -1133,6 +1133,21 @@ func (mr *MockStoreMockRecorder) GetUserByUsername(arg0 interface{}) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUsername", reflect.TypeOf((*MockStore)(nil).GetUserByUsername), arg0) } +// GetUserCategories mocks base method. +func (m *MockStore) GetUserCategories(arg0, arg1 string) ([]model.Category, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserCategories", arg0, arg1) + ret0, _ := ret[0].([]model.Category) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserCategories indicates an expected call of GetUserCategories. +func (mr *MockStoreMockRecorder) GetUserCategories(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCategories", reflect.TypeOf((*MockStore)(nil).GetUserCategories), arg0, arg1) +} + // GetUserCategoryBoards mocks base method. func (m *MockStore) GetUserCategoryBoards(arg0, arg1 string) ([]model.CategoryBoards, error) { m.ctrl.T.Helper() @@ -1382,6 +1397,36 @@ func (mr *MockStoreMockRecorder) RemoveDefaultTemplates(arg0 interface{}) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveDefaultTemplates", reflect.TypeOf((*MockStore)(nil).RemoveDefaultTemplates), arg0) } +// ReorderCategories mocks base method. +func (m *MockStore) ReorderCategories(arg0, arg1 string, arg2 []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReorderCategories", arg0, arg1, arg2) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReorderCategories indicates an expected call of ReorderCategories. +func (mr *MockStoreMockRecorder) ReorderCategories(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReorderCategories", reflect.TypeOf((*MockStore)(nil).ReorderCategories), arg0, arg1, arg2) +} + +// ReorderCategoryBoards mocks base method. +func (m *MockStore) ReorderCategoryBoards(arg0 string, arg1 []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReorderCategoryBoards", arg0, arg1) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReorderCategoryBoards indicates an expected call of ReorderCategoryBoards. +func (mr *MockStoreMockRecorder) ReorderCategoryBoards(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReorderCategoryBoards", reflect.TypeOf((*MockStore)(nil).ReorderCategoryBoards), arg0, arg1) +} + // RunDataRetention mocks base method. func (m *MockStore) RunDataRetention(arg0, arg1 int64) (int64, error) { m.ctrl.T.Helper() diff --git a/server/services/store/sqlstore/category.go b/server/services/store/sqlstore/category.go index 23272ae05..847c3d3d9 100644 --- a/server/services/store/sqlstore/category.go +++ b/server/services/store/sqlstore/category.go @@ -2,6 +2,7 @@ package sqlstore import ( "database/sql" + "fmt" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" @@ -10,9 +11,11 @@ import ( "github.com/mattermost/mattermost-server/v6/shared/mlog" ) +const categorySortOrderGap = 10 + func (s *SQLStore) getCategory(db sq.BaseRunner, id string) (*model.Category, error) { query := s.getQueryBuilder(db). - Select("id", "name", "user_id", "team_id", "create_at", "update_at", "delete_at", "collapsed", "type"). + Select("id", "name", "user_id", "team_id", "create_at", "update_at", "delete_at", "collapsed", "sort_order", "type"). From(s.tablePrefix + "categories"). Where(sq.Eq{"id": id}) @@ -36,6 +39,11 @@ func (s *SQLStore) getCategory(db sq.BaseRunner, id string) (*model.Category, er } func (s *SQLStore) createCategory(db sq.BaseRunner, category model.Category) error { + // A new category should always end up at the top. + // So we first insert the provided category, then bump up + // existing user-team categories' order + + // creating provided category query := s.getQueryBuilder(db). Insert(s.tablePrefix+"categories"). Columns( @@ -47,6 +55,7 @@ func (s *SQLStore) createCategory(db sq.BaseRunner, category model.Category) err "update_at", "delete_at", "collapsed", + "sort_order", "type", ). Values( @@ -58,6 +67,7 @@ func (s *SQLStore) createCategory(db sq.BaseRunner, category model.Category) err category.UpdateAt, category.DeleteAt, category.Collapsed, + category.SortOrder, category.Type, ) @@ -66,6 +76,30 @@ func (s *SQLStore) createCategory(db sq.BaseRunner, category model.Category) err s.logger.Error("Error creating category", mlog.String("category name", category.Name), mlog.Err(err)) return err } + + // bumping up order of existing categories + updateQuery := s.getQueryBuilder(db). + Update(s.tablePrefix+"categories"). + Set("sort_order", sq.Expr(fmt.Sprintf("sort_order + %d", categorySortOrderGap))). + Where( + sq.Eq{ + "user_id": category.UserID, + "team_id": category.TeamID, + "delete_at": 0, + }, + ) + + if _, err := updateQuery.Exec(); err != nil { + s.logger.Error( + "createCategory failed to update sort order of existing user-team categories", + mlog.String("user_id", category.UserID), + mlog.String("team_id", category.TeamID), + mlog.Err(err), + ) + + return err + } + return nil } @@ -115,13 +149,14 @@ func (s *SQLStore) deleteCategory(db sq.BaseRunner, categoryID, userID, teamID s func (s *SQLStore) getUserCategories(db sq.BaseRunner, userID, teamID string) ([]model.Category, error) { query := s.getQueryBuilder(db). - Select("id", "name", "user_id", "team_id", "create_at", "update_at", "delete_at", "collapsed", "type"). - From(s.tablePrefix + "categories"). + Select("id", "name", "user_id", "team_id", "create_at", "update_at", "delete_at", "collapsed", "sort_order", "type"). + From(s.tablePrefix+"categories"). Where(sq.Eq{ "user_id": userID, "team_id": teamID, "delete_at": 0, - }) + }). + OrderBy("sort_order", "name") rows, err := query.Query() if err != nil { @@ -146,6 +181,7 @@ func (s *SQLStore) categoriesFromRows(rows *sql.Rows) ([]model.Category, error) &category.UpdateAt, &category.DeleteAt, &category.Collapsed, + &category.SortOrder, &category.Type, ) @@ -159,3 +195,36 @@ func (s *SQLStore) categoriesFromRows(rows *sql.Rows) ([]model.Category, error) return categories, nil } + +func (s *SQLStore) reorderCategories(db sq.BaseRunner, userID, teamID string, newCategoryOrder []string) ([]string, error) { + if len(newCategoryOrder) == 0 { + return nil, nil + } + + updateCase := sq.Case("id") + for i, categoryID := range newCategoryOrder { + updateCase = updateCase.When("'"+categoryID+"'", sq.Expr(fmt.Sprintf("%d", i*categorySortOrderGap))) + } + updateCase = updateCase.Else("sort_order") + + query := s.getQueryBuilder(db). + Update(s.tablePrefix+"categories"). + Set("sort_order", updateCase). + Where(sq.Eq{ + "user_id": userID, + "team_id": teamID, + }) + + if _, err := query.Exec(); err != nil { + s.logger.Error( + "reorderCategories failed to update category order", + mlog.String("user_id", userID), + mlog.String("team_id", teamID), + mlog.Err(err), + ) + + return nil, err + } + + return newCategoryOrder, nil +} diff --git a/server/services/store/sqlstore/category_boards.go b/server/services/store/sqlstore/category_boards.go index 8a0e36172..6a8415c67 100644 --- a/server/services/store/sqlstore/category_boards.go +++ b/server/services/store/sqlstore/category_boards.go @@ -2,6 +2,7 @@ package sqlstore import ( "database/sql" + "fmt" sq "github.com/Masterminds/squirrel" "github.com/mattermost/focalboard/server/model" @@ -41,7 +42,8 @@ func (s *SQLStore) getCategoryBoardAttributes(db sq.BaseRunner, categoryID strin Where(sq.Eq{ "category_id": categoryID, "delete_at": 0, - }) + }). + OrderBy("sort_order") rows, err := query.Query() if err != nil { @@ -52,23 +54,25 @@ func (s *SQLStore) getCategoryBoardAttributes(db sq.BaseRunner, categoryID strin return s.categoryBoardsFromRows(rows) } -func (s *SQLStore) addUpdateCategoryBoard(db sq.BaseRunner, userID, categoryID, boardID string) error { - if err := s.deleteUserCategoryBoard(db, userID, boardID); err != nil { +func (s *SQLStore) addUpdateCategoryBoard(db sq.BaseRunner, userID string, boardCategoryMapping map[string]string) error { + boardIDs := []string{} + for boardID := range boardCategoryMapping { + boardIDs = append(boardIDs, boardID) + } + + if err := s.deleteUserCategoryBoards(db, userID, boardIDs); err != nil { return err } - if categoryID == "0" { - // category ID "0" means user wants to move board out of - // the custom category. Deleting the user-board-category - // mapping achieves this. + return s.addUserCategoryBoard(db, userID, boardCategoryMapping) +} + +func (s *SQLStore) addUserCategoryBoard(db sq.BaseRunner, userID string, boardCategoryMapping map[string]string) error { + if len(boardCategoryMapping) == 0 { return nil } - return s.addUserCategoryBoard(db, userID, categoryID, boardID) -} - -func (s *SQLStore) addUserCategoryBoard(db sq.BaseRunner, userID, categoryID, boardID string) error { - _, err := s.getQueryBuilder(db). + query := s.getQueryBuilder(db). Insert(s.tablePrefix+"category_boards"). Columns( "id", @@ -78,39 +82,50 @@ func (s *SQLStore) addUserCategoryBoard(db sq.BaseRunner, userID, categoryID, bo "create_at", "update_at", "delete_at", - ). - Values( - utils.NewID(utils.IDTypeNone), - userID, - categoryID, - boardID, - utils.GetMillis(), - utils.GetMillis(), - 0, - ).Exec() + "sort_order", + ) - if err != nil { + now := utils.GetMillis() + for boardID, categoryID := range boardCategoryMapping { + query = query. + Values( + utils.NewID(utils.IDTypeNone), + userID, + categoryID, + boardID, + now, + now, + 0, + 0, + ) + } + + if _, err := query.Exec(); err != nil { s.logger.Error("addUserCategoryBoard error", mlog.Err(err)) return err } return nil } -func (s *SQLStore) deleteUserCategoryBoard(db sq.BaseRunner, userID, boardID string) error { +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": boardID, + "board_id": boardIDs, "delete_at": 0, }).Exec() if err != nil { s.logger.Error( - "deleteUserCategoryBoard delete error", + "deleteUserCategoryBoards delete error", mlog.String("userID", userID), - mlog.String("boardID", boardID), + mlog.Array("boardID", boardIDs), mlog.Err(err), ) return err @@ -134,3 +149,35 @@ func (s *SQLStore) categoryBoardsFromRows(rows *sql.Rows) ([]string, error) { return blocks, nil } + +func (s *SQLStore) reorderCategoryBoards(db sq.BaseRunner, categoryID string, newBoardsOrder []string) ([]string, error) { + if len(newBoardsOrder) == 0 { + return nil, nil + } + + updateCase := sq.Case("board_id") + for i, boardID := range newBoardsOrder { + updateCase = updateCase.When("'"+boardID+"'", sq.Expr(fmt.Sprintf("%d", i+model.CategoryBoardsSortOrderGap))) + } + updateCase.Else("sort_order") + + query := s.getQueryBuilder(db). + Update(s.tablePrefix+"category_boards"). + Set("sort_order", updateCase). + Where(sq.Eq{ + "category_id": categoryID, + "delete_at": 0, + }) + + if _, err := query.Exec(); err != nil { + s.logger.Error( + "reorderCategoryBoards failed to update category board order", + mlog.String("category_id", categoryID), + mlog.Err(err), + ) + + return nil, err + } + + return newBoardsOrder, nil +} diff --git a/server/services/store/sqlstore/migrations/000030_add_category_sort_order.down.sql b/server/services/store/sqlstore/migrations/000030_add_category_sort_order.down.sql new file mode 100644 index 000000000..027b7d63f --- /dev/null +++ b/server/services/store/sqlstore/migrations/000030_add_category_sort_order.down.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000030_add_category_sort_order.up.sql b/server/services/store/sqlstore/migrations/000030_add_category_sort_order.up.sql new file mode 100644 index 000000000..f8a1bf0cd --- /dev/null +++ b/server/services/store/sqlstore/migrations/000030_add_category_sort_order.up.sql @@ -0,0 +1 @@ +ALTER TABLE {{.prefix}}categories ADD COLUMN sort_order BIGINT DEFAULT 0; diff --git a/server/services/store/sqlstore/migrations/000031_add_category_boards_sort_order.down.sql b/server/services/store/sqlstore/migrations/000031_add_category_boards_sort_order.down.sql new file mode 100644 index 000000000..027b7d63f --- /dev/null +++ b/server/services/store/sqlstore/migrations/000031_add_category_boards_sort_order.down.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000031_add_category_boards_sort_order.up.sql b/server/services/store/sqlstore/migrations/000031_add_category_boards_sort_order.up.sql new file mode 100644 index 000000000..fc023e724 --- /dev/null +++ b/server/services/store/sqlstore/migrations/000031_add_category_boards_sort_order.up.sql @@ -0,0 +1 @@ +ALTER TABLE {{.prefix}}category_boards ADD COLUMN sort_order BIGINT DEFAULT 0; diff --git a/server/services/store/sqlstore/migrations/000032_move_boards_category_to_end.down.sql b/server/services/store/sqlstore/migrations/000032_move_boards_category_to_end.down.sql new file mode 100644 index 000000000..027b7d63f --- /dev/null +++ b/server/services/store/sqlstore/migrations/000032_move_boards_category_to_end.down.sql @@ -0,0 +1 @@ +SELECT 1; \ No newline at end of file diff --git a/server/services/store/sqlstore/migrations/000032_move_boards_category_to_end.up.sql b/server/services/store/sqlstore/migrations/000032_move_boards_category_to_end.up.sql new file mode 100644 index 000000000..5b333a09e --- /dev/null +++ b/server/services/store/sqlstore/migrations/000032_move_boards_category_to_end.up.sql @@ -0,0 +1,15 @@ +{{- /* To move Boards category to to the last value, we just need a relatively large value. */ -}} +{{- /* Assigning 10x total number of categories works perfectly. The sort_order is anyways updated */ -}} +{{- /* when the user manually DNDs a category. */ -}} + +{{if or .postgres .sqlite}} +UPDATE {{.prefix}}categories SET sort_order = (10 * (SELECT COUNT(*) FROM {{.prefix}}categories)) WHERE lower(name) = 'boards'; +{{end}} + +{{if .mysql}} +{{- /* MySQL doesn't allow referencing the same table in subquery and update query like Postgres, */ -}} +{{- /* So we save the subquery result in a variable to use later. */ -}} +SET @focalboad_numCategories = (SELECT COUNT(*) FROM {{.prefix}}categories); +UPDATE {{.prefix}}categories SET sort_order = (10 * @focalboad_numCategories) WHERE lower(name) = 'boards'; +SET @focalboad_numCategories = NULL; +{{end}} \ No newline at end of file diff --git a/server/services/store/sqlstore/public_methods.go b/server/services/store/sqlstore/public_methods.go index 7cba92307..fcc83202b 100644 --- a/server/services/store/sqlstore/public_methods.go +++ b/server/services/store/sqlstore/public_methods.go @@ -22,15 +22,15 @@ import ( "github.com/mattermost/mattermost-server/v6/shared/mlog" ) -func (s *SQLStore) AddUpdateCategoryBoard(userID string, categoryID string, blockID string) error { +func (s *SQLStore) AddUpdateCategoryBoard(userID string, boardCategoryMapping map[string]string) error { if s.dbType == model.SqliteDBType { - return s.addUpdateCategoryBoard(s.db, userID, categoryID, blockID) + return s.addUpdateCategoryBoard(s.db, userID, boardCategoryMapping) } tx, txErr := s.db.BeginTx(context.Background(), nil) if txErr != nil { return txErr } - err := s.addUpdateCategoryBoard(tx, userID, categoryID, blockID) + err := s.addUpdateCategoryBoard(tx, userID, boardCategoryMapping) if err != nil { if rollbackErr := tx.Rollback(); rollbackErr != nil { s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "AddUpdateCategoryBoard")) @@ -105,7 +105,26 @@ func (s *SQLStore) CreateBoardsAndBlocksWithAdmin(bab *model.BoardsAndBlocks, us } func (s *SQLStore) CreateCategory(category model.Category) error { - return s.createCategory(s.db, category) + if s.dbType == model.SqliteDBType { + return s.createCategory(s.db, category) + } + tx, txErr := s.db.BeginTx(context.Background(), nil) + if txErr != nil { + return txErr + } + err := s.createCategory(tx, category) + if err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "CreateCategory")) + } + return err + } + + if err := tx.Commit(); err != nil { + return err + } + + return nil } @@ -539,6 +558,11 @@ func (s *SQLStore) GetUserByUsername(username string) (*model.User, error) { } +func (s *SQLStore) GetUserCategories(userID string, teamID string) ([]model.Category, error) { + return s.getUserCategories(s.db, userID, teamID) + +} + func (s *SQLStore) GetUserCategoryBoards(userID string, teamID string) ([]model.CategoryBoards, error) { return s.getUserCategoryBoards(s.db, userID, teamID) @@ -757,6 +781,16 @@ func (s *SQLStore) RemoveDefaultTemplates(boards []*model.Board) error { } +func (s *SQLStore) ReorderCategories(userID string, teamID string, newCategoryOrder []string) ([]string, error) { + return s.reorderCategories(s.db, userID, teamID, newCategoryOrder) + +} + +func (s *SQLStore) ReorderCategoryBoards(categoryID string, newBoardsOrder []string) ([]string, error) { + return s.reorderCategoryBoards(s.db, categoryID, newBoardsOrder) + +} + func (s *SQLStore) RunDataRetention(globalRetentionDate int64, batchSize int64) (int64, error) { if s.dbType == model.SqliteDBType { return s.runDataRetention(s.db, globalRetentionDate, batchSize) diff --git a/server/services/store/sqlstore/sqlstore_test.go b/server/services/store/sqlstore/sqlstore_test.go index 0fc9aff4c..e802655c7 100644 --- a/server/services/store/sqlstore/sqlstore_test.go +++ b/server/services/store/sqlstore/sqlstore_test.go @@ -6,8 +6,9 @@ package sqlstore import ( "testing" - "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store/storetests" + + "github.com/mattermost/focalboard/server/model" "github.com/stretchr/testify/require" ) diff --git a/server/services/store/store.go b/server/services/store/store.go index a072a0e86..4a8ae7948 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -117,9 +117,13 @@ type Store interface { DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks, userID string) error GetCategory(id string) (*model.Category, error) + + GetUserCategories(userID, teamID string) ([]model.Category, error) + // @withTransaction CreateCategory(category model.Category) error UpdateCategory(category model.Category) error DeleteCategory(categoryID, userID, teamID string) error + ReorderCategories(userID, teamID string, newCategoryOrder []string) ([]string, error) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) @@ -127,7 +131,8 @@ type Store interface { SaveFileInfo(fileInfo *mmModel.FileInfo) error // @withTransaction - AddUpdateCategoryBoard(userID, categoryID, blockID string) error + AddUpdateCategoryBoard(userID string, boardCategoryMapping map[string]string) error + ReorderCategoryBoards(categoryID string, newBoardsOrder []string) ([]string, error) CreateSubscription(sub *model.Subscription) (*model.Subscription, error) DeleteSubscription(blockID string, subscriberID string) error diff --git a/server/services/store/storetests/category.go b/server/services/store/storetests/category.go index e9d2db1a7..113e13b2b 100644 --- a/server/services/store/storetests/category.go +++ b/server/services/store/storetests/category.go @@ -9,27 +9,25 @@ import ( "github.com/stretchr/testify/assert" ) +type testFunc func(t *testing.T, store store.Store) + func StoreTestCategoryStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { - t.Run("CreateCategory", func(t *testing.T) { - store, tearDown := setup(t) - defer tearDown() - testGetCreateCategory(t, store) - }) - t.Run("UpdateCategory", func(t *testing.T) { - store, tearDown := setup(t) - defer tearDown() - testUpdateCategory(t, store) - }) - t.Run("DeleteCategory", func(t *testing.T) { - store, tearDown := setup(t) - defer tearDown() - testDeleteCategory(t, store) - }) - t.Run("GetUserCategories", func(t *testing.T) { - store, tearDown := setup(t) - defer tearDown() - testGetUserCategories(t, store) - }) + tests := map[string]testFunc{ + "CreateCategory": testGetCreateCategory, + "UpdateCategory": testUpdateCategory, + "DeleteCategory": testDeleteCategory, + "GetUserCategories": testGetUserCategories, + "ReorderCategories": testReorderCategories, + "ReorderCategoriesBoards": testReorderCategoryBoards, + } + + for name, f := range tests { + t.Run(name, func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + f(t, store) + }) + } } func testGetCreateCategory(t *testing.T, store store.Store) { @@ -211,3 +209,129 @@ func testGetUserCategories(t *testing.T, store store.Store) { assert.NoError(t, err) assert.Equal(t, 3, len(userCategories)) } + +func testReorderCategories(t *testing.T, store store.Store) { + // setup + err := store.CreateCategory(model.Category{ + ID: "category_id_1", + Name: "Category 1", + Type: "custom", + UserID: "user_id", + TeamID: "team_id", + }) + assert.NoError(t, err) + + err = store.CreateCategory(model.Category{ + ID: "category_id_2", + Name: "Category 2", + Type: "custom", + UserID: "user_id", + TeamID: "team_id", + }) + assert.NoError(t, err) + + err = store.CreateCategory(model.Category{ + ID: "category_id_3", + Name: "Category 3", + Type: "custom", + UserID: "user_id", + TeamID: "team_id", + }) + assert.NoError(t, err) + + // verify the current order + categories, err := store.GetUserCategories("user_id", "team_id") + assert.NoError(t, err) + assert.Equal(t, 3, len(categories)) + + // the categories should show up in reverse insertion order (latest one first) + assert.Equal(t, "category_id_3", categories[0].ID) + assert.Equal(t, "category_id_2", categories[1].ID) + assert.Equal(t, "category_id_1", categories[2].ID) + + // re-ordering categories normally + _, err = store.ReorderCategories("user_id", "team_id", []string{ + "category_id_2", + "category_id_3", + "category_id_1", + }) + assert.NoError(t, err) + + // verify the board order + categories, err = store.GetUserCategories("user_id", "team_id") + assert.NoError(t, err) + assert.Equal(t, 3, len(categories)) + assert.Equal(t, "category_id_2", categories[0].ID) + assert.Equal(t, "category_id_3", categories[1].ID) + assert.Equal(t, "category_id_1", categories[2].ID) + + // lets try specifying a non existing category ID. + // It shouldn't cause any problem + _, err = store.ReorderCategories("user_id", "team_id", []string{ + "category_id_1", + "category_id_2", + "category_id_3", + "non-existing-category-id", + }) + assert.NoError(t, err) + + categories, err = store.GetUserCategories("user_id", "team_id") + assert.NoError(t, err) + assert.Equal(t, 3, len(categories)) + assert.Equal(t, "category_id_1", categories[0].ID) + assert.Equal(t, "category_id_2", categories[1].ID) + assert.Equal(t, "category_id_3", categories[2].ID) +} + +func testReorderCategoryBoards(t *testing.T, store store.Store) { + // setup + err := store.CreateCategory(model.Category{ + ID: "category_id_1", + Name: "Category 1", + Type: "custom", + UserID: "user_id", + TeamID: "team_id", + }) + 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", + }) + assert.NoError(t, err) + + // verify current order + 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") + + // reordering + newOrder, err := store.ReorderCategoryBoards("category_id_1", []string{ + "board_id_3", + "board_id_1", + "board_id_2", + "board_id_4", + }) + assert.NoError(t, err) + assert.Equal(t, "board_id_3", newOrder[0]) + assert.Equal(t, "board_id_1", newOrder[1]) + assert.Equal(t, "board_id_2", newOrder[2]) + assert.Equal(t, "board_id_4", newOrder[3]) + + // verify new order + 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]) +} diff --git a/server/services/store/storetests/categoryBoards.go b/server/services/store/storetests/categoryBoards.go index 0cc5f4bf9..2cc83b197 100644 --- a/server/services/store/storetests/categoryBoards.go +++ b/server/services/store/storetests/categoryBoards.go @@ -60,14 +60,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", "category_id_1", "board_1") + err = store.AddUpdateCategoryBoard("user_id_1", map[string]string{"board_1": "category_id_1"}) assert.NoError(t, err) - err = store.AddUpdateCategoryBoard("user_id_1", "category_id_1", "board_2") + err = store.AddUpdateCategoryBoard("user_id_1", map[string]string{"board_2": "category_id_1"}) assert.NoError(t, err) // Adding Board 3 to Category 2 - err = store.AddUpdateCategoryBoard("user_id_1", "category_id_2", "board_3") + err = store.AddUpdateCategoryBoard("user_id_1", map[string]string{"board_3": "category_id_2"}) assert.NoError(t, err) // we'll leave category 3 empty diff --git a/server/services/store/storetests/data_retention.go b/server/services/store/storetests/data_retention.go index e89beed3b..a73d40afa 100644 --- a/server/services/store/storetests/data_retention.go +++ b/server/services/store/storetests/data_retention.go @@ -92,7 +92,7 @@ func LoadData(t *testing.T, store store.Store) { err = store.UpsertSharing(sharing) require.NoError(t, err) - err = store.AddUpdateCategoryBoard(testUserID, categoryID, boardID) + err = store.AddUpdateCategoryBoard(testUserID, map[string]string{boardID: categoryID}) require.NoError(t, err) } diff --git a/server/ws/adapter.go b/server/ws/adapter.go index e283c64e6..21a351f6f 100644 --- a/server/ws/adapter.go +++ b/server/ws/adapter.go @@ -20,6 +20,8 @@ const ( websocketActionUpdateCategoryBoard = "UPDATE_BOARD_CATEGORY" websocketActionUpdateSubscription = "UPDATE_SUBSCRIPTION" websocketActionUpdateCardLimitTimestamp = "UPDATE_CARD_LIMIT_TIMESTAMP" + websocketActionReorderCategories = "REORDER_CATEGORIES" + websocketActionReorderCategoryBoards = "REORDER_CATEGORY_BOARDS" ) type Store interface { @@ -36,7 +38,9 @@ type Adapter interface { BroadcastMemberDelete(teamID, boardID, userID string) BroadcastConfigChange(clientConfig model.ClientConfig) BroadcastCategoryChange(category model.Category) - BroadcastCategoryBoardChange(teamID, userID string, blockCategory model.BoardCategoryWebsocketData) + BroadcastCategoryBoardChange(teamID, userID string, blockCategory []*model.BoardCategoryWebsocketData) BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) + BroadcastCategoryReorder(teamID, userID string, categoryOrder []string) + BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardsOrder []string) } diff --git a/server/ws/common.go b/server/ws/common.go index 8941628c0..c21adc285 100644 --- a/server/ws/common.go +++ b/server/ws/common.go @@ -6,10 +6,10 @@ import ( // UpdateCategoryMessage is sent on block updates. type UpdateCategoryMessage struct { - Action string `json:"action"` - TeamID string `json:"teamId"` - Category *model.Category `json:"category,omitempty"` - BoardCategories *model.BoardCategoryWebsocketData `json:"blockCategories,omitempty"` + Action string `json:"action"` + TeamID string `json:"teamId"` + Category *model.Category `json:"category,omitempty"` + BoardCategories []*model.BoardCategoryWebsocketData `json:"blockCategories,omitempty"` } // UpdateBlockMsg is sent on block updates. @@ -59,3 +59,16 @@ type WebsocketCommand struct { ReadToken string `json:"readToken"` BlockIDs []string `json:"blockIds"` } + +type CategoryReorderMessage struct { + Action string `json:"action"` + CategoryOrder []string `json:"categoryOrder"` + TeamID string `json:"teamId"` +} + +type CategoryBoardReorderMessage struct { + Action string `json:"action"` + CategoryID string `json:"CategoryId"` + BoardOrder []string `json:"BoardOrder"` + TeamID string `json:"teamId"` +} diff --git a/server/ws/plugin_adapter.go b/server/ws/plugin_adapter.go index dcb26ce98..f45cae629 100644 --- a/server/ws/plugin_adapter.go +++ b/server/ws/plugin_adapter.go @@ -496,19 +496,68 @@ func (pa *PluginAdapter) BroadcastCategoryChange(category model.Category) { pa.sendUserMessageSkipCluster(websocketActionUpdateCategory, payload, category.UserID) } -func (pa *PluginAdapter) BroadcastCategoryBoardChange(teamID, userID string, boardCategory model.BoardCategoryWebsocketData) { +func (pa *PluginAdapter) BroadcastCategoryReorder(teamID, userID string, categoryOrder []string) { + pa.logger.Debug("BroadcastCategoryReorder", + mlog.String("userID", userID), + mlog.String("teamID", teamID), + ) + + message := CategoryReorderMessage{ + Action: websocketActionReorderCategories, + CategoryOrder: categoryOrder, + TeamID: teamID, + } + payload := utils.StructToMap(message) + go func() { + clusterMessage := &ClusterMessage{ + Payload: payload, + UserID: userID, + } + + pa.sendMessageToCluster("websocket_message", clusterMessage) + }() + + pa.sendUserMessageSkipCluster(message.Action, payload, userID) +} + +func (pa *PluginAdapter) BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardsOrder []string) { + pa.logger.Debug("BroadcastCategoryBoardsReorder", + mlog.String("userID", userID), + mlog.String("teamID", teamID), + mlog.String("categoryID", categoryID), + ) + + message := CategoryBoardReorderMessage{ + Action: websocketActionReorderCategoryBoards, + CategoryID: categoryID, + BoardOrder: boardsOrder, + TeamID: teamID, + } + payload := utils.StructToMap(message) + go func() { + clusterMessage := &ClusterMessage{ + Payload: payload, + UserID: userID, + } + + pa.sendMessageToCluster("websocket_message", clusterMessage) + }() + + pa.sendUserMessageSkipCluster(message.Action, payload, userID) +} + +func (pa *PluginAdapter) BroadcastCategoryBoardChange(teamID, userID string, boardCategories []*model.BoardCategoryWebsocketData) { pa.logger.Debug( "BroadcastCategoryBoardChange", mlog.String("userID", userID), mlog.String("teamID", teamID), - mlog.String("categoryID", boardCategory.CategoryID), - mlog.String("blockID", boardCategory.BoardID), + mlog.Int("numEntries", len(boardCategories)), ) message := UpdateCategoryMessage{ Action: websocketActionUpdateCategoryBoard, TeamID: teamID, - BoardCategories: &boardCategory, + BoardCategories: boardCategories, } payload := utils.StructToMap(message) diff --git a/server/ws/server.go b/server/ws/server.go index 044e1f09e..9df1b1f6e 100644 --- a/server/ws/server.go +++ b/server/ws/server.go @@ -589,27 +589,80 @@ func (ws *Server) BroadcastCategoryChange(category model.Category) { } } -func (ws *Server) BroadcastCategoryBoardChange(teamID, userID string, boardCategory model.BoardCategoryWebsocketData) { - message := UpdateCategoryMessage{ - Action: websocketActionUpdateCategoryBoard, - TeamID: teamID, - BoardCategories: &boardCategory, +func (ws *Server) BroadcastCategoryReorder(teamID, userID string, categoryOrder []string) { + message := CategoryReorderMessage{ + Action: websocketActionReorderCategories, + CategoryOrder: categoryOrder, + TeamID: teamID, } listeners := ws.getListenersForTeam(teamID) ws.logger.Debug("listener(s) for teamID", mlog.Int("listener_count", len(listeners)), mlog.String("teamID", teamID), - mlog.String("categoryID", boardCategory.CategoryID), - mlog.String("blockID", boardCategory.BoardID), + ) + + for _, listener := range listeners { + ws.logger.Debug("Broadcast category order change", + mlog.Int("listener_count", len(listeners)), + mlog.String("teamID", teamID), + mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), + ) + + if err := listener.WriteJSON(message); err != nil { + ws.logger.Error("broadcast category order change error", mlog.Err(err)) + listener.conn.Close() + } + } +} + +func (ws *Server) BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardOrder []string) { + message := CategoryBoardReorderMessage{ + Action: websocketActionReorderCategoryBoards, + CategoryID: categoryID, + BoardOrder: boardOrder, + TeamID: teamID, + } + + listeners := ws.getListenersForTeam(teamID) + ws.logger.Debug("listener(s) for teamID", + mlog.Int("listener_count", len(listeners)), + mlog.String("teamID", teamID), + ) + + for _, listener := range listeners { + ws.logger.Debug("Broadcast board category order change", + mlog.Int("listener_count", len(listeners)), + mlog.String("teamID", teamID), + mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), + ) + + if err := listener.WriteJSON(message); err != nil { + ws.logger.Error("broadcast category order change error", mlog.Err(err)) + listener.conn.Close() + } + } +} + +func (ws *Server) BroadcastCategoryBoardChange(teamID, userID string, boardCategories []*model.BoardCategoryWebsocketData) { + message := UpdateCategoryMessage{ + Action: websocketActionUpdateCategoryBoard, + TeamID: teamID, + BoardCategories: boardCategories, + } + + listeners := ws.getListenersForTeam(teamID) + ws.logger.Debug("listener(s) for teamID", + mlog.Int("listener_count", len(listeners)), + mlog.String("teamID", teamID), + mlog.Int("numEntries", len(boardCategories)), ) for _, listener := range listeners { ws.logger.Debug("Broadcast block change", mlog.Int("listener_count", len(listeners)), mlog.String("teamID", teamID), - mlog.String("categoryID", boardCategory.CategoryID), - mlog.String("blockID", boardCategory.BoardID), + mlog.Int("numEntries", len(boardCategories)), mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()), ) diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index dbec7ec92..a716d5176 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -244,6 +244,8 @@ "Sidebar.template-from-board": "New template from board", "Sidebar.untitled-board": "(Untitled Board)", "Sidebar.untitled-view": "(Untitled View)", + "Sidebar.new-category.badge": "New", + "Sidebar.new-category.drag-boards-cta": "Drag boards here...", "SidebarCategories.BlocksMenu.Move": "Move To...", "SidebarCategories.CategoryMenu.CreateNew": "Create New Category", "SidebarCategories.CategoryMenu.Delete": "Delete Category", diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 026de05ce..0dd50b1b3 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -35,6 +35,7 @@ "moment": "^2.29.1", "nanoevents": "^5.1.13", "react": "^16.13.0", + "react-beautiful-dnd": "^13.1.1", "react-day-picker": "^7.4.10", "react-dnd": "^14.0.2", "react-dnd-html5-backend": "^14.0.0", @@ -65,6 +66,7 @@ "@types/marked": "^4.0.3", "@types/nanoevents": "^1.0.0", "@types/react": "^17.0.43", + "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.0.14", "@types/react-intl": "^3.0.0", "@types/react-redux": "^7.1.23", @@ -2715,6 +2717,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-beautiful-dnd": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.2.tgz", + "integrity": "sha512-+OvPkB8CdE/bGdXKyIhc/Lm2U7UAYCCJgsqmopFmh9gbAudmslkI8eOrPDjg4JhwSE6wytz4a3/wRjKtovHVJg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "17.0.17", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.17.tgz", @@ -5629,6 +5640,14 @@ "source-map-resolve": "^0.6.0" } }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, "node_modules/css-functions-list": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.1.0.tgz", @@ -13670,6 +13689,11 @@ "performance-now": "^2.1.0" } }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -13725,6 +13749,24 @@ "node": ">=0.10.0" } }, + "node_modules/react-beautiful-dnd": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz", + "integrity": "sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==", + "dependencies": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.5 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-custom-scrollbars-2": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/react-custom-scrollbars-2/-/react-custom-scrollbars-2-4.5.0.tgz", @@ -16676,6 +16718,14 @@ "node": ">= 4" } }, + "node_modules/use-memo-one": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", + "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -19464,6 +19514,15 @@ "csstype": "^3.0.2" } }, + "@types/react-beautiful-dnd": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.2.tgz", + "integrity": "sha512-+OvPkB8CdE/bGdXKyIhc/Lm2U7UAYCCJgsqmopFmh9gbAudmslkI8eOrPDjg4JhwSE6wytz4a3/wRjKtovHVJg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-dom": { "version": "17.0.17", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.17.tgz", @@ -21726,6 +21785,14 @@ "source-map-resolve": "^0.6.0" } }, + "css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "requires": { + "tiny-invariant": "^1.0.6" + } + }, "css-functions-list": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.1.0.tgz", @@ -27755,6 +27822,11 @@ "performance-now": "^2.1.0" } }, + "raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -27800,6 +27872,20 @@ "prop-types": "^15.6.2" } }, + "react-beautiful-dnd": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz", + "integrity": "sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==", + "requires": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + } + }, "react-custom-scrollbars-2": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/react-custom-scrollbars-2/-/react-custom-scrollbars-2-4.5.0.tgz", @@ -30068,6 +30154,12 @@ "resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz", "integrity": "sha512-0kQLIzG4fdk/G5NONku64rSH/x32NOA39LVQqlK8Le6lvTF6GGRJpqaQFGgU+CLwySIqBSMdwYM0sYcW9f6P4A==" }, + "use-memo-one": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", + "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==", + "requires": {} + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/webapp/package.json b/webapp/package.json index b0f2d43af..0e2056504 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -52,6 +52,7 @@ "moment": "^2.29.1", "nanoevents": "^5.1.13", "react": "^16.13.0", + "react-beautiful-dnd": "^13.1.1", "react-day-picker": "^7.4.10", "react-dnd": "^14.0.2", "react-dnd-html5-backend": "^14.0.0", @@ -106,6 +107,7 @@ "@types/marked": "^4.0.3", "@types/nanoevents": "^1.0.0", "@types/react": "^17.0.43", + "@types/react-beautiful-dnd": "^13.1.2", "@types/react-dom": "^17.0.14", "@types/react-intl": "^3.0.0", "@types/react-redux": "^7.1.23", diff --git a/webapp/src/components/__snapshots__/workspace.test.tsx.snap b/webapp/src/components/__snapshots__/workspace.test.tsx.snap index e06582101..c1e09cb06 100644 --- a/webapp/src/components/__snapshots__/workspace.test.tsx.snap +++ b/webapp/src/components/__snapshots__/workspace.test.tsx.snap @@ -110,100 +110,127 @@ exports[`src/components/workspace return workspace and showcard 1`] = `
- - Category 1 -
-
-
-
-
-
-
- i -
-
- board title -
-
- +
- - + +
-
-
-
@@ -1209,100 +1236,127 @@ exports[`src/components/workspace should match snapshot 1`] = `
- - Category 1 -
-
-
-
-
-
-
- i -
-
- board title -
-
- +
- - + +
-
-
-
diff --git a/webapp/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap b/webapp/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap index 485203dee..a64d71207 100644 --- a/webapp/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap +++ b/webapp/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap @@ -108,49 +108,70 @@ exports[`components/sidebarSidebar dont show hidden boards 1`] = `
- - Category 1 -
-
-
+
+
+ No boards inside +
-
- No boards inside -
- - Category 1 -
-
-
- - Boards -
-
-
+
+
+ No boards inside +
-
- No boards inside -
- - Category 1 -
-
-
- - Category 2 -
-
-
-
-
-
-
- i -
-
- board title -
-
- - Boards -
-
-
+
+
+ No boards inside +
-
- No boards inside -