1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-12-24 13:43:12 +02:00

Revert "Revert dnd (#4294)" (#4324)

This reverts commit 9fac8f476e.
This commit is contained in:
Harshil Sharma 2022-12-14 14:21:53 +05:30 committed by GitHub
parent f2d98f940f
commit eceb406535
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 4375 additions and 1918 deletions

View File

@ -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) => {

View File

@ -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()
}

View File

@ -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

View File

@ -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,12 @@ func TestBoardCategory(t *testing.T) {
Name: "Boards",
}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, 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().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)
boards := []*model.Board{
{ID: "board_id_1"},
@ -449,3 +452,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)
})
}

View File

@ -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
}

View File

@ -1,6 +1,7 @@
package app
import (
"errors"
"fmt"
"github.com/mattermost/focalboard/server/model"
@ -8,6 +9,10 @@ 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")
var errBoardMembershipNotFound = errors.New("board membership not found for user's board")
func (a *App) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) {
categoryBoards, err := a.store.GetUserCategoryBoards(userID, teamID)
if err != nil {
@ -53,6 +58,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 {
@ -61,23 +67,40 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
// once the category is created, we need to move all boards which do not
// belong to any category, into this category.
boardMembers, err := a.GetMembersForUser(userID)
if err != nil {
return nil, fmt.Errorf("createBoardsCategory error fetching user's board memberships: %w", err)
}
boardMemberByBoardID := map[string]*model.BoardMember{}
for _, boardMember := range boardMembers {
boardMemberByBoardID[boardMember.BoardID] = boardMember
}
createdCategoryBoards := &model.CategoryBoards{
Category: *createdCategory,
BoardIDs: []string{},
}
for _, bm := range boardMembers {
// get user's current team's baords
userTeamBoards, err := a.GetBoardsForUserAndTeam(userID, teamID, false)
if err != nil {
return nil, fmt.Errorf("createBoardsCategory error fetching user's team's boards: %w", err)
}
for _, board := range userTeamBoards {
boardMembership, ok := boardMemberByBoardID[board.ID]
if !ok {
return nil, fmt.Errorf("createBoardsCategory: %w", errBoardMembershipNotFound)
}
// boards with implicit access (aka synthetic membership),
// should show up in LHS only when openign them explicitelly.
// So we don't process any synthetic membership boards
// and only add boards with explicit access to, to the the LHS,
// for example, if a user explicitelly added another user to a board.
if bm.Synthetic {
if boardMembership.Synthetic {
continue
}
@ -85,7 +108,7 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
for _, categoryBoard := range existingCategoryBoards {
for _, boardID := range categoryBoard.BoardIDs {
if boardID == bm.BoardID {
if boardID == board.ID {
belongsToCategory = true
break
}
@ -99,36 +122,111 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
}
if !belongsToCategory {
if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, bm.BoardID); err != nil {
// ToDo: por siaca
// 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)
}
createdCategoryBoards.BoardIDs = append(createdCategoryBoards.BoardIDs, bm.BoardID)
createdCategoryBoards.BoardIDs = append(createdCategoryBoards.BoardIDs, board.ID)
}
}
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
}

View File

@ -21,6 +21,20 @@ func TestGetUserCategoryBoards(t *testing.T) {
Name: "Boards",
}, nil)
board1 := &model.Board{
ID: "board_id_1",
}
board2 := &model.Board{
ID: "board_id_2",
}
board3 := &model.Board{
ID: "board_id_3",
}
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
@ -35,10 +49,9 @@ func TestGetUserCategoryBoards(t *testing.T) {
Synthetic: false,
},
}, 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)
@ -59,6 +72,7 @@ func TestGetUserCategoryBoards(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)
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
@ -94,6 +108,7 @@ func TestCreateBoardsCategory(t *testing.T) {
Type: "system",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
existingCategoryBoards := []model.CategoryBoards{}
@ -111,6 +126,7 @@ func TestCreateBoardsCategory(t *testing.T) {
Type: "system",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
@ -144,6 +160,17 @@ func TestCreateBoardsCategory(t *testing.T) {
Type: "system",
Name: "Boards",
}, nil)
board1 := &model.Board{
ID: "board_id_1",
}
board2 := &model.Board{
ID: "board_id_2",
}
board3 := &model.Board{
ID: "board_id_3",
}
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
@ -158,9 +185,9 @@ func TestCreateBoardsCategory(t *testing.T) {
Synthetic: false,
},
}, 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)
existingCategoryBoards := []model.CategoryBoards{}
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
@ -180,6 +207,11 @@ func TestCreateBoardsCategory(t *testing.T) {
Type: "system",
Name: "Boards",
}, nil)
board1 := &model.Board{
ID: "board_id_1",
}
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
@ -194,7 +226,7 @@ func TestCreateBoardsCategory(t *testing.T) {
Synthetic: true,
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_1").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
existingCategoryBoards := []model.CategoryBoards{}
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
@ -208,3 +240,54 @@ func TestCreateBoardsCategory(t *testing.T) {
assert.Equal(t, 1, len(boardsCategory.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)
})
}

View File

@ -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().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)
})
}
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)
})
}

View File

@ -55,8 +55,9 @@ func TestApp_ImportArchive(t *testing.T) {
ID: "boards_category_id",
Name: "Boards",
}, 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", "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")

View File

@ -77,7 +77,8 @@ func TestPrepareOnboardingTour(t *testing.T) {
ID: "boards_category",
Name: "Boards",
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", "board_id_2").Return(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)
teamID, boardID, err := th.App.PrepareOnboardingTour(userID, teamID)
assert.NoError(t, err)
@ -120,7 +121,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)

View File

@ -443,6 +443,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 {
@ -465,6 +474,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 {

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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()

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1 @@
ALTER TABLE {{.prefix}}categories ADD COLUMN sort_order BIGINT DEFAULT 0;

View File

@ -0,0 +1 @@
ALTER TABLE {{.prefix}}category_boards ADD COLUMN sort_order BIGINT DEFAULT 0;

View File

@ -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}}

View File

@ -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)

View File

@ -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"
)

View File

@ -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

View File

@ -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])
}

View File

@ -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

View File

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

View File

@ -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)
}

View File

@ -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"`
}

View File

@ -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)

View File

@ -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()),
)

View File

@ -257,6 +257,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",

View File

@ -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",

View File

@ -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",

View File

@ -110,100 +110,127 @@ exports[`src/components/workspace return workspace and showcard 1`] = `
</div>
<div
class="octo-sidebar-list"
data-rbd-droppable-context-id="1"
data-rbd-droppable-id="lhs-categories"
>
<div
class="SidebarCategory"
data-rbd-draggable-context-id="1"
>
<div
class="octo-sidebar-item category ' expanded active"
class="SidebarCategory"
>
<div
class="octo-sidebar-title category-title"
title="Category 1"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
class="categoryBoardsDroppableArea"
data-rbd-droppable-context-id="1"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
class="octo-sidebar-item category expanded active"
>
<button
type="button"
<div
aria-describedby="rbd-hidden-text-1-hidden-text-5"
class="octo-sidebar-title category-title"
data-rbd-drag-handle-context-id="1"
draggable="false"
role="button"
tabindex="0"
title="Category 1"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
</button>
</div>
</div>
</div>
<div
class="SidebarBoardItem subitem active"
>
<div
class="octo-sidebar-icon"
>
i
</div>
<div
class="octo-sidebar-title"
title="board title"
>
board title
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper x"
role="button"
>
<button
type="button"
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
<div
class="SidebarBoardItem sidebar-view-item active"
>
<svg
class="BoardIcon Icon"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g
opacity="0.8"
<div
data-rbd-draggable-context-id="1"
data-rbd-draggable-id="board1"
>
<path
clip-rule="evenodd"
d="M4 4H20V20H4V4ZM2 4C2 2.89543 2.89543 2 4 2H20C21.1046 2 22 2.89543 22 4V20C22 21.1046 21.1046 22 20 22H4C2.89543 22 2 21.1046 2 20V4ZM8 6H6V12H8V6ZM11 6H13V16H11V6ZM18 6H16V9H18V6Z"
fill="currentColor"
fill-rule="evenodd"
/>
</g>
</svg>
<div
class="octo-sidebar-title"
title="view title"
>
view title
<div
aria-describedby="rbd-hidden-text-1-hidden-text-5"
class="SidebarBoardItem subitem active"
data-rbd-drag-handle-context-id="1"
data-rbd-drag-handle-draggable-id="board1"
draggable="false"
role="button"
tabindex="0"
>
<div
class="octo-sidebar-icon"
>
i
</div>
<div
class="octo-sidebar-title"
title="board title"
>
board title
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper x"
role="button"
>
<button
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
<div
class="SidebarBoardItem sidebar-view-item active"
>
<svg
class="BoardIcon Icon"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g
opacity="0.8"
>
<path
clip-rule="evenodd"
d="M4 4H20V20H4V4ZM2 4C2 2.89543 2.89543 2 4 2H20C21.1046 2 22 2.89543 22 4V20C22 21.1046 21.1046 22 20 22H4C2.89543 22 2 21.1046 2 20V4ZM8 6H6V12H8V6ZM11 6H13V16H11V6ZM18 6H16V9H18V6Z"
fill="currentColor"
fill-rule="evenodd"
/>
</g>
</svg>
<div
class="octo-sidebar-title"
title="view title"
>
view title
</div>
</div>
</div>
</div>
</div>
</div>
@ -1209,100 +1236,127 @@ exports[`src/components/workspace should match snapshot 1`] = `
</div>
<div
class="octo-sidebar-list"
data-rbd-droppable-context-id="0"
data-rbd-droppable-id="lhs-categories"
>
<div
class="SidebarCategory"
data-rbd-draggable-context-id="0"
>
<div
class="octo-sidebar-item category ' expanded active"
class="SidebarCategory"
>
<div
class="octo-sidebar-title category-title"
title="Category 1"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
class="categoryBoardsDroppableArea"
data-rbd-droppable-context-id="0"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
class="octo-sidebar-item category expanded active"
>
<button
type="button"
<div
aria-describedby="rbd-hidden-text-0-hidden-text-0"
class="octo-sidebar-title category-title"
data-rbd-drag-handle-context-id="0"
draggable="false"
role="button"
tabindex="0"
title="Category 1"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
</button>
</div>
</div>
</div>
<div
class="SidebarBoardItem subitem active"
>
<div
class="octo-sidebar-icon"
>
i
</div>
<div
class="octo-sidebar-title"
title="board title"
>
board title
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper x"
role="button"
>
<button
type="button"
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
<div
class="SidebarBoardItem sidebar-view-item active"
>
<svg
class="BoardIcon Icon"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g
opacity="0.8"
<div
data-rbd-draggable-context-id="0"
data-rbd-draggable-id="board1"
>
<path
clip-rule="evenodd"
d="M4 4H20V20H4V4ZM2 4C2 2.89543 2.89543 2 4 2H20C21.1046 2 22 2.89543 22 4V20C22 21.1046 21.1046 22 20 22H4C2.89543 22 2 21.1046 2 20V4ZM8 6H6V12H8V6ZM11 6H13V16H11V6ZM18 6H16V9H18V6Z"
fill="currentColor"
fill-rule="evenodd"
/>
</g>
</svg>
<div
class="octo-sidebar-title"
title="view title"
>
view title
<div
aria-describedby="rbd-hidden-text-0-hidden-text-0"
class="SidebarBoardItem subitem active"
data-rbd-drag-handle-context-id="0"
data-rbd-drag-handle-draggable-id="board1"
draggable="false"
role="button"
tabindex="0"
>
<div
class="octo-sidebar-icon"
>
i
</div>
<div
class="octo-sidebar-title"
title="board title"
>
board title
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper x"
role="button"
>
<button
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
<div
class="SidebarBoardItem sidebar-view-item active"
>
<svg
class="BoardIcon Icon"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g
opacity="0.8"
>
<path
clip-rule="evenodd"
d="M4 4H20V20H4V4ZM2 4C2 2.89543 2.89543 2 4 2H20C21.1046 2 22 2.89543 22 4V20C22 21.1046 21.1046 22 20 22H4C2.89543 22 2 21.1046 2 20V4ZM8 6H6V12H8V6ZM11 6H13V16H11V6ZM18 6H16V9H18V6Z"
fill="currentColor"
fill-rule="evenodd"
/>
</g>
</svg>
<div
class="octo-sidebar-title"
title="view title"
>
view title
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -108,49 +108,70 @@ exports[`components/sidebarSidebar dont show hidden boards 1`] = `
</div>
<div
class="octo-sidebar-list"
data-rbd-droppable-context-id="2"
data-rbd-droppable-id="lhs-categories"
>
<div
class="SidebarCategory"
data-rbd-draggable-context-id="2"
data-rbd-draggable-id="category1"
>
<div
class="octo-sidebar-item category ' expanded "
class="SidebarCategory"
>
<div
class="octo-sidebar-title category-title"
title="Category 1"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
class="categoryBoardsDroppableArea"
data-rbd-droppable-context-id="2"
data-rbd-droppable-id="category1"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
class="octo-sidebar-item category expanded "
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-2-hidden-text-10"
class="octo-sidebar-title category-title"
data-rbd-drag-handle-context-id="2"
data-rbd-drag-handle-draggable-id="category1"
draggable="false"
role="button"
tabindex="0"
title="Category 1"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
</button>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
<div>
<div
class="octo-sidebar-item subitem no-views"
>
No boards inside
</div>
</div>
</div>
</div>
<div
class="octo-sidebar-item subitem no-views"
>
No boards inside
</div>
</div>
</div>
<div
@ -288,87 +309,125 @@ exports[`components/sidebarSidebar should assign default category if current boa
</div>
<div
class="octo-sidebar-list"
data-rbd-droppable-context-id="4"
data-rbd-droppable-id="lhs-categories"
>
<div
class="SidebarCategory"
data-rbd-draggable-context-id="4"
data-rbd-draggable-id="category1"
>
<div
class="octo-sidebar-item category ' expanded "
class="SidebarCategory"
>
<div
class="octo-sidebar-title category-title"
title="Category 1"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
class="categoryBoardsDroppableArea"
data-rbd-droppable-context-id="4"
data-rbd-droppable-id="category1"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
class="octo-sidebar-item category expanded "
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-4-hidden-text-21"
class="octo-sidebar-title category-title"
data-rbd-drag-handle-context-id="4"
data-rbd-drag-handle-draggable-id="category1"
draggable="false"
role="button"
tabindex="0"
title="Category 1"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
</button>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="SidebarCategory"
data-rbd-draggable-context-id="4"
data-rbd-draggable-id="default_category"
>
<div
class="octo-sidebar-item category ' expanded "
class="SidebarCategory"
>
<div
class="octo-sidebar-title category-title"
title="Boards"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Boards
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
class="categoryBoardsDroppableArea"
data-rbd-droppable-context-id="4"
data-rbd-droppable-id="default_category"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
class="octo-sidebar-item category expanded "
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-4-hidden-text-21"
class="octo-sidebar-title category-title"
data-rbd-drag-handle-context-id="4"
data-rbd-drag-handle-draggable-id="default_category"
draggable="false"
role="button"
tabindex="0"
title="Boards"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
</button>
Boards
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
<div>
<div
class="octo-sidebar-item subitem no-views"
>
No boards inside
</div>
</div>
</div>
</div>
<div
class="octo-sidebar-item subitem no-views"
>
No boards inside
</div>
</div>
</div>
<div
@ -506,156 +565,222 @@ exports[`components/sidebarSidebar shouldnt do any category assignment is board
</div>
<div
class="octo-sidebar-list"
data-rbd-droppable-context-id="5"
data-rbd-droppable-id="lhs-categories"
>
<div
class="SidebarCategory"
data-rbd-draggable-context-id="5"
data-rbd-draggable-id="category1"
>
<div
class="octo-sidebar-item category ' expanded "
class="SidebarCategory"
>
<div
class="octo-sidebar-title category-title"
title="Category 1"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
class="categoryBoardsDroppableArea"
data-rbd-droppable-context-id="5"
data-rbd-droppable-id="category1"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
class="octo-sidebar-item category expanded "
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-5-hidden-text-27"
class="octo-sidebar-title category-title"
data-rbd-drag-handle-context-id="5"
data-rbd-drag-handle-draggable-id="category1"
draggable="false"
role="button"
tabindex="0"
title="Category 1"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
</button>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="SidebarCategory"
data-rbd-draggable-context-id="5"
data-rbd-draggable-id="category2"
>
<div
class="octo-sidebar-item category ' expanded "
class="SidebarCategory"
>
<div
class="octo-sidebar-title category-title"
title="Category 2"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Category 2
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
class="categoryBoardsDroppableArea"
data-rbd-droppable-context-id="5"
data-rbd-droppable-id="category2"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
class="octo-sidebar-item category expanded "
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-5-hidden-text-27"
class="octo-sidebar-title category-title"
data-rbd-drag-handle-context-id="5"
data-rbd-drag-handle-draggable-id="category2"
draggable="false"
role="button"
tabindex="0"
title="Category 2"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
</button>
Category 2
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
<div
class="SidebarBoardItem subitem "
>
<div
class="octo-sidebar-icon"
>
i
</div>
<div
class="octo-sidebar-title"
title="board title"
>
board title
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper x"
role="button"
data-rbd-draggable-context-id="5"
data-rbd-draggable-id="board2"
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-5-hidden-text-27"
class="SidebarBoardItem subitem "
data-rbd-drag-handle-context-id="5"
data-rbd-drag-handle-draggable-id="board2"
draggable="false"
role="button"
tabindex="0"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
<div
class="octo-sidebar-icon"
>
i
</div>
<div
class="octo-sidebar-title"
title="board title"
>
board title
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper x"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="SidebarCategory"
data-rbd-draggable-context-id="5"
data-rbd-draggable-id="default_category"
>
<div
class="octo-sidebar-item category ' expanded "
class="SidebarCategory"
>
<div
class="octo-sidebar-title category-title"
title="Boards"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Boards
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
class="categoryBoardsDroppableArea"
data-rbd-droppable-context-id="5"
data-rbd-droppable-id="default_category"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
class="octo-sidebar-item category expanded "
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-5-hidden-text-27"
class="octo-sidebar-title category-title"
data-rbd-drag-handle-context-id="5"
data-rbd-drag-handle-draggable-id="default_category"
draggable="false"
role="button"
tabindex="0"
title="Boards"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
</button>
Boards
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
<div>
<div
class="octo-sidebar-item subitem no-views"
>
No boards inside
</div>
</div>
</div>
</div>
<div
class="octo-sidebar-item subitem no-views"
>
No boards inside
</div>
</div>
</div>
<div
@ -851,72 +976,102 @@ exports[`components/sidebarSidebar sidebar hidden 1`] = `
</div>
<div
class="octo-sidebar-list"
data-rbd-droppable-context-id="0"
data-rbd-droppable-id="lhs-categories"
>
<div
class="SidebarCategory"
data-rbd-draggable-context-id="0"
data-rbd-draggable-id="category1"
>
<div
class="octo-sidebar-item category ' expanded "
class="SidebarCategory"
>
<div
class="octo-sidebar-title category-title"
title="Category 1"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
class="categoryBoardsDroppableArea"
data-rbd-droppable-context-id="0"
data-rbd-droppable-id="category1"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
class="octo-sidebar-item category expanded "
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-0-hidden-text-0"
class="octo-sidebar-title category-title"
data-rbd-drag-handle-context-id="0"
data-rbd-drag-handle-draggable-id="category1"
draggable="false"
role="button"
tabindex="0"
title="Category 1"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
</button>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
<div
class="SidebarBoardItem subitem "
>
<div
class="octo-sidebar-icon"
>
i
</div>
<div
class="octo-sidebar-title"
title="board title"
>
board title
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper x"
role="button"
data-rbd-draggable-context-id="0"
data-rbd-draggable-id="board1"
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-0-hidden-text-0"
class="SidebarBoardItem subitem "
data-rbd-drag-handle-context-id="0"
data-rbd-drag-handle-draggable-id="board1"
draggable="false"
role="button"
tabindex="0"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
<div
class="octo-sidebar-icon"
>
i
</div>
<div
class="octo-sidebar-title"
title="board title"
>
board title
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper x"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@ -1115,110 +1270,157 @@ exports[`components/sidebarSidebar some categories hidden 1`] = `
</div>
<div
class="octo-sidebar-list"
data-rbd-droppable-context-id="3"
data-rbd-droppable-id="lhs-categories"
>
<div
class="SidebarCategory"
data-rbd-draggable-context-id="3"
data-rbd-draggable-id="category1"
>
<div
class="octo-sidebar-item category ' expanded "
class="SidebarCategory"
>
<div
class="octo-sidebar-title category-title"
title="Category 1"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
class="categoryBoardsDroppableArea"
data-rbd-droppable-context-id="3"
data-rbd-droppable-id="category1"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
class="octo-sidebar-item category expanded "
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-3-hidden-text-14"
class="octo-sidebar-title category-title"
data-rbd-drag-handle-context-id="3"
data-rbd-drag-handle-draggable-id="category1"
draggable="false"
role="button"
tabindex="0"
title="Category 1"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
</button>
Category 1
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
<div
class="SidebarBoardItem subitem "
>
<div
class="octo-sidebar-icon"
>
i
</div>
<div
class="octo-sidebar-title"
title="board title"
>
board title
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper x"
role="button"
data-rbd-draggable-context-id="3"
data-rbd-draggable-id="board1"
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-3-hidden-text-14"
class="SidebarBoardItem subitem "
data-rbd-drag-handle-context-id="3"
data-rbd-drag-handle-draggable-id="board1"
draggable="false"
role="button"
tabindex="0"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
<div
class="octo-sidebar-icon"
>
i
</div>
<div
class="octo-sidebar-title"
title="board title"
>
board title
</div>
<div>
<div
aria-label="menuwrapper"
class="MenuWrapper x"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="SidebarCategory"
data-rbd-draggable-context-id="3"
data-rbd-draggable-id="categoryCollapsed"
>
<div
class="octo-sidebar-item category ' collapsed "
class="SidebarCategory"
>
<div
class="octo-sidebar-title category-title"
title="Category 2"
>
<i
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
Category 2
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
class="categoryBoardsDroppableArea"
data-rbd-droppable-context-id="3"
data-rbd-droppable-id="categoryCollapsed"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
class="octo-sidebar-item category collapsed "
>
<button
class="IconButton"
type="button"
<div
aria-describedby="rbd-hidden-text-3-hidden-text-14"
class="octo-sidebar-title category-title"
data-rbd-drag-handle-context-id="3"
data-rbd-drag-handle-draggable-id="categoryCollapsed"
draggable="false"
role="button"
tabindex="0"
title="Category 2"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
class="CompassIcon icon-chevron-right ChevronRightIcon"
/>
</button>
Category 2
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -39,6 +39,7 @@ describe('components/sidebarSidebar', () => {
board.id = 'board1'
const categoryAttribute1 = TestBlockFactory.createCategoryBoards()
categoryAttribute1.id = 'category1'
categoryAttribute1.name = 'Category 1'
categoryAttribute1.boardIDs = [board.id]
@ -232,6 +233,7 @@ describe('components/sidebarSidebar', () => {
test('some categories hidden', () => {
const collapsedCategory = TestBlockFactory.createCategoryBoards()
collapsedCategory.id = 'categoryCollapsed'
collapsedCategory.name = 'Category 2'
collapsedCategory.collapsed = true
@ -351,6 +353,7 @@ describe('components/sidebarSidebar', () => {
board2.id = 'board2'
const categoryAttribute2 = TestBlockFactory.createCategoryBoards()
categoryAttribute2.id = 'category2'
categoryAttribute2.name = 'Category 2'
categoryAttribute2.boardIDs = [board2.id]

View File

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react'
import React, {useCallback, useEffect, useState} from 'react'
import {FormattedMessage} from 'react-intl'
import {DragDropContext, Droppable, DropResult} from 'react-beautiful-dnd'
import {getActiveThemeName, loadTheme} from '../../theme'
import IconButton from '../../widgets/buttons/iconButton'
@ -20,15 +21,18 @@ import {
Category,
CategoryBoards,
fetchSidebarCategories,
getSidebarCategories, updateBoardCategories,
getSidebarCategories,
updateBoardCategories,
updateCategories,
updateCategoryBoardsOrder,
updateCategoryOrder,
} from '../../store/sidebar'
import BoardsSwitcher from '../boardsSwitcher/boardsSwitcher'
import wsClient, {WSClient} from '../../wsclient'
import {getCurrentTeam} from '../../store/teams'
import {getCurrentTeam, getCurrentTeamId} from '../../store/teams'
import {Constants} from '../../constants'
@ -37,6 +41,12 @@ import {getCurrentViewId} from '../../store/views'
import octoClient from '../../octoClient'
import {useWebsockets} from '../../hooks/websockets'
import mutator from '../../mutator'
import {Board} from '../../blocks/board'
import SidebarCategory from './sidebarCategory'
import SidebarSettingsMenu from './sidebarSettingsMenu'
import SidebarUserMenu from './sidebarUserMenu'
@ -76,6 +86,7 @@ const Sidebar = (props: Props) => {
}, 'blockCategories')
}, [])
const teamId = useAppSelector(getCurrentTeamId)
const team = useAppSelector(getCurrentTeam)
useEffect(() => {
@ -132,6 +143,17 @@ const Sidebar = (props: Props) => {
octoClient.moveBoardToCategory(team.id, currentBoard.id, boardsCategory.id, '')
}, [sidebarCategories, currentBoard, team])
useWebsockets(teamId, (websocketClient: WSClient) => {
const onCategoryReorderHandler = (_: WSClient, newCategoryOrder: string[]): void => {
dispatch(updateCategoryOrder(newCategoryOrder))
}
websocketClient.addOnChange(onCategoryReorderHandler, 'categoryOrder')
return () => {
websocketClient.removeOnChange(onCategoryReorderHandler, 'categoryOrder')
}
}, [teamId])
if (!boards) {
return <div/>
}
@ -146,6 +168,109 @@ const Sidebar = (props: Props) => {
}
}
const handleCategoryDND = useCallback(async (result: DropResult) => {
const {destination, source} = result
if (!team || !destination) {
return
}
const categories = sidebarCategories
// creating a mutable copy
const newCategories = Array.from(categories)
// remove category from old index
newCategories.splice(source.index, 1)
// add it to new index
newCategories.splice(destination.index, 0, categories[source.index])
const newCategoryOrder = newCategories.map((category) => category.id)
// optimistically updating the store to produce a lag-free UI
await dispatch(updateCategoryOrder(newCategoryOrder))
await octoClient.reorderSidebarCategories(team.id, newCategoryOrder)
}, [team, sidebarCategories])
const handleCategoryBoardDND = useCallback(async (result: DropResult) => {
const {source, destination, draggableId} = result
if (!team || !destination) {
return
}
const fromCategoryID = source.droppableId
const toCategoryID = destination.droppableId
const boardID = draggableId
if (fromCategoryID === toCategoryID) {
// board re-arranged withing the same category
const toSidebarCategory = sidebarCategories.find((category) => category.id === toCategoryID)
if (!toSidebarCategory) {
Utils.logError(`toCategoryID not found in list of sidebar categories. toCategoryID: ${toCategoryID}`)
return
}
const boardIDs = [...toSidebarCategory.boardIDs]
boardIDs.splice(source.index, 1)
boardIDs.splice(destination.index, 0, toSidebarCategory.boardIDs[source.index])
dispatch(updateCategoryBoardsOrder({categoryID: toCategoryID, boardIDs}))
await octoClient.reorderSidebarCategoryBoards(team.id, toCategoryID, boardIDs)
} else {
// board moved to a different category
const toSidebarCategory = sidebarCategories.find((category) => category.id === toCategoryID)
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)
// optimistically updating the store to create a lag-free UI.
await dispatch(updateCategoryBoardsOrder({categoryID: toCategoryID, boardIDs}))
dispatch(updateBoardCategories([{boardID, categoryID: toCategoryID}]))
await mutator.moveBoardToCategory(team.id, boardID, toCategoryID, fromCategoryID)
await octoClient.reorderSidebarCategoryBoards(team.id, toCategoryID, boardIDs)
}
}, [team, sidebarCategories])
const onDragEnd = useCallback(async (result: DropResult) => {
const {destination, source, type} = result
if (!team || !destination) {
setDraggedItemID('')
setIsCategoryBeingDragged(false)
return
}
if (destination.droppableId === source.droppableId && destination.index === source.index) {
setDraggedItemID('')
setIsCategoryBeingDragged(false)
return
}
if (type === 'category') {
handleCategoryDND(result)
} else if (type === 'board') {
handleCategoryBoardDND(result)
} else {
Utils.logWarn(`unknown drag type encountered, type: ${type}`)
}
setDraggedItemID('')
setIsCategoryBeingDragged(false)
}, [team, sidebarCategories])
const [draggedItemID, setDraggedItemID] = useState<string>('')
const [isCategoryBeingDragged, setIsCategoryBeingDragged] = useState<boolean>(false)
if (!boards) {
return <div/>
}
if (!me) {
return <div/>
}
@ -177,6 +302,26 @@ const Sidebar = (props: Props) => {
)
}
const getSortedCategoryBoards = (category: CategoryBoards): Board[] => {
const categoryBoardsByID = new Map<string, Board>()
boards.forEach((board) => {
if (!category.boardIDs.includes(board.id)) {
return
}
categoryBoardsByID.set(board.id, board)
})
const sortedBoards: Board[] = []
category.boardIDs.forEach((boardID) => {
const b = categoryBoardsByID.get(boardID)
if (b) {
sortedBoards.push(b)
}
})
return sortedBoards
}
return (
<div className='Sidebar octo-sidebar'>
{!Utils.isFocalboardPlugin() &&
@ -221,23 +366,42 @@ const Sidebar = (props: Props) => {
userIsGuest={me?.is_guest}
/>
<div className='octo-sidebar-list'>
{
sidebarCategories.map((category, index) => (
<SidebarCategory
hideSidebar={hideSidebar}
key={category.id}
activeBoardID={props.activeBoardId}
activeViewID={activeViewID}
categoryBoards={category}
boards={boards}
allCategories={sidebarCategories}
index={index}
onBoardTemplateSelectorClose={props.onBoardTemplateSelectorClose}
/>
))
}
</div>
<DragDropContext
onDragEnd={onDragEnd}
>
<Droppable
droppableId='lhs-categories'
type='category'
key={sidebarCategories.length}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className='octo-sidebar-list'
>
{
sidebarCategories.map((category, index) => (
<SidebarCategory
hideSidebar={hideSidebar}
key={category.id}
activeBoardID={props.activeBoardId}
activeViewID={activeViewID}
categoryBoards={category}
boards={getSortedCategoryBoards(category)}
allCategories={sidebarCategories}
index={index}
onBoardTemplateSelectorClose={props.onBoardTemplateSelectorClose}
draggedItemID={draggedItemID}
forceCollapse={isCategoryBeingDragged}
/>
))
}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<div className='octo-spacer'/>

View File

@ -14,12 +14,13 @@ import configureStore from 'redux-mock-store'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {wrapIntl} from '../../testUtils'
import {wrapIntl, wrapRBDNDDroppable} from '../../testUtils'
import SidebarBoardItem from './sidebarBoardItem'
describe('components/sidebarBoardItem', () => {
const board = TestBlockFactory.createBoard()
board.id = 'board_id_1'
const view = TestBlockFactory.createBoardView(board)
view.fields.sortOptions = []
@ -73,10 +74,11 @@ describe('components/sidebarBoardItem', () => {
const mockStore = configureStore([])
const store = mockStore(state)
const component = wrapIntl(
const component = wrapRBDNDDroppable(wrapIntl(
<ReduxProvider store={store}>
<Router history={history}>
<SidebarBoardItem
index={0}
categoryBoards={categoryBoards1}
board={board}
allCategories={allCategoryBoards}
@ -87,7 +89,7 @@ describe('components/sidebarBoardItem', () => {
/>
</Router>
</ReduxProvider>,
)
))
const {container} = render(component)
const elementMenuWrapper = container.querySelector('.SidebarBoardItem div.MenuWrapper')
expect(elementMenuWrapper).not.toBeNull()
@ -100,10 +102,11 @@ describe('components/sidebarBoardItem', () => {
const store = mockStore(state)
const noIconBoard = {...board, icon: ''}
const component = wrapIntl(
const component = wrapRBDNDDroppable(wrapIntl(
<ReduxProvider store={store}>
<Router history={history}>
<SidebarBoardItem
index={0}
categoryBoards={categoryBoards1}
board={noIconBoard}
allCategories={allCategoryBoards}
@ -114,7 +117,7 @@ describe('components/sidebarBoardItem', () => {
/>
</Router>
</ReduxProvider>,
)
))
const {container} = render(component)
expect(container).toMatchSnapshot()
})
@ -123,10 +126,11 @@ describe('components/sidebarBoardItem', () => {
const mockStore = configureStore([])
const store = mockStore({...state, users: {me: {is_guest: true}}})
const component = wrapIntl(
const component = wrapRBDNDDroppable(wrapIntl(
<ReduxProvider store={store}>
<Router history={history}>
<SidebarBoardItem
index={0}
categoryBoards={categoryBoards1}
board={board}
allCategories={allCategoryBoards}
@ -137,7 +141,7 @@ describe('components/sidebarBoardItem', () => {
/>
</Router>
</ReduxProvider>,
)
))
const {container} = render(component)
const elementMenuWrapper = container.querySelector('.SidebarBoardItem div.MenuWrapper')
expect(elementMenuWrapper).not.toBeNull()

View File

@ -3,6 +3,7 @@
import React, {useCallback, useRef, useState} from 'react'
import {useIntl} from 'react-intl'
import {generatePath, useHistory, useRouteMatch} from 'react-router-dom'
import {Draggable} from 'react-beautiful-dnd'
import {Board} from '../../blocks/board'
import {BoardView, IViewType} from '../../blocks/boardView'
@ -59,6 +60,9 @@ type Props = {
onDeleteRequest: (board: Board) => void
showBoard: (boardId: string) => void
showView: (viewId: string, boardId: string) => void
index: number
draggedItemID?: string
hideViews?: boolean
}
const SidebarBoardItem = (props: Props) => {
@ -196,110 +200,122 @@ const SidebarBoardItem = (props: Props) => {
const title = board.title || intl.formatMessage({id: 'Sidebar.untitled-board', defaultMessage: '(Untitled Board)'})
return (
<>
<div
className={`SidebarBoardItem subitem ${props.isActive ? 'active' : ''}`}
onClick={() => props.showBoard(board.id)}
ref={boardItemRef}
>
<div className='octo-sidebar-icon'>
{board.icon || <CompassIcon icon='product-boards'/>}
</div>
<Draggable
draggableId={props.board.id}
key={props.board.id}
index={props.index}
>
{(provided, snapshot) => (
<div
className='octo-sidebar-title'
title={title}
{...provided.draggableProps}
ref={provided.innerRef}
>
{title}
</div>
<div>
<MenuWrapper
className={boardsMenuOpen[board.id] ? 'menuOpen' : 'x'}
stopPropagationOnToggle={true}
onToggle={(open) => {
setBoardsMenuOpen((menuState) => {
const newState = {...menuState}
newState[board.id] = open
return newState
})
}}
>
<IconButton icon={<OptionsIcon/>}/>
<Menu
fixed={true}
position='auto'
parentRef={boardItemRef}
>
<Menu.SubMenu
key={`moveBlock-${board.id}`}
id='moveBlock'
className='boardMoveToCategorySubmenu'
name={intl.formatMessage({id: 'SidebarCategories.BlocksMenu.Move', defaultMessage: 'Move To...'})}
icon={<CreateNewFolder/>}
position='auto'
>
{generateMoveToCategoryOptions(board.id)}
</Menu.SubMenu>
{!me?.is_guest &&
<Menu.Text
id='duplicateBoard'
name={intl.formatMessage({id: 'Sidebar.duplicate-board', defaultMessage: 'Duplicate board'})}
icon={<DuplicateIcon/>}
onClick={() => handleDuplicateBoard(board.isTemplate)}
/>}
{!me?.is_guest &&
<Menu.Text
id='templateFromBoard'
name={intl.formatMessage({id: 'Sidebar.template-from-board', defaultMessage: 'New template from board'})}
icon={<AddIcon/>}
onClick={() => handleDuplicateBoard(true)}
/>}
<Menu.Text
id='exportBoardArchive'
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export board archive'})}
icon={<CompassIcon icon='export-variant'/>}
onClick={() => Archiver.exportBoardArchive(board)}
/>
<Menu.Text
id='hideBoard'
name={intl.formatMessage({id: 'HideBoard.MenuOption', defaultMessage: 'Hide board'})}
icon={<CloseIcon/>}
onClick={() => handleHideBoard()}
/>
<BoardPermissionGate
boardId={board.id}
permissions={[Permission.DeleteBoard]}
>
<Menu.Text
key={`deleteBlock-${board.id}`}
id='deleteBlock'
className='text-danger'
name={intl.formatMessage({id: 'Sidebar.delete-board', defaultMessage: 'Delete board'})}
icon={<DeleteIcon/>}
onClick={() => {
props.onDeleteRequest(board)
}}
/>
</BoardPermissionGate>
</Menu>
</MenuWrapper>
</div>
</div>
{props.isActive && boardViews.map((view: BoardView) => (
<div
key={view.id}
className={`SidebarBoardItem sidebar-view-item ${view.id === currentViewId ? 'active' : ''}`}
onClick={() => props.showView(view.id, board.id)}
>
{iconForViewType(view.fields.viewType)}
<div
className='octo-sidebar-title'
title={view.title || intl.formatMessage({id: 'Sidebar.untitled-view', defaultMessage: '(Untitled View)'})}
{...provided.dragHandleProps}
className={`SidebarBoardItem subitem ${props.isActive ? 'active' : ''}`}
onClick={() => props.showBoard(board.id)}
ref={boardItemRef}
>
{view.title || intl.formatMessage({id: 'Sidebar.untitled-view', defaultMessage: '(Untitled View)'})}
<div className='octo-sidebar-icon'>
{board.icon || <CompassIcon icon='product-boards'/>}
</div>
<div
className='octo-sidebar-title'
title={title}
>
{title}
</div>
<div>
<MenuWrapper
className={boardsMenuOpen[board.id] ? 'menuOpen' : 'x'}
stopPropagationOnToggle={true}
onToggle={(open) => {
setBoardsMenuOpen((menuState) => {
const newState = {...menuState}
newState[board.id] = open
return newState
})
}}
>
<IconButton icon={<OptionsIcon/>}/>
<Menu
fixed={true}
position='auto'
parentRef={boardItemRef}
>
<Menu.SubMenu
key={`moveBlock-${board.id}`}
id='moveBlock'
className='boardMoveToCategorySubmenu'
name={intl.formatMessage({id: 'SidebarCategories.BlocksMenu.Move', defaultMessage: 'Move To...'})}
icon={<CreateNewFolder/>}
position='auto'
>
{generateMoveToCategoryOptions(board.id)}
</Menu.SubMenu>
{!me?.is_guest &&
<Menu.Text
id='duplicateBoard'
name={intl.formatMessage({id: 'Sidebar.duplicate-board', defaultMessage: 'Duplicate board'})}
icon={<DuplicateIcon/>}
onClick={() => handleDuplicateBoard(board.isTemplate)}
/>}
{!me?.is_guest &&
<Menu.Text
id='templateFromBoard'
name={intl.formatMessage({id: 'Sidebar.template-from-board', defaultMessage: 'New template from board'})}
icon={<AddIcon/>}
onClick={() => handleDuplicateBoard(true)}
/>}
<Menu.Text
id='exportBoardArchive'
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export board archive'})}
icon={<CompassIcon icon='export-variant'/>}
onClick={() => Archiver.exportBoardArchive(board)}
/>
<Menu.Text
id='hideBoard'
name={intl.formatMessage({id: 'HideBoard.MenuOption', defaultMessage: 'Hide board'})}
icon={<CloseIcon/>}
onClick={() => handleHideBoard()}
/>
<BoardPermissionGate
boardId={board.id}
permissions={[Permission.DeleteBoard]}
>
<Menu.Text
key={`deleteBlock-${board.id}`}
id='deleteBlock'
className='text-danger'
name={intl.formatMessage({id: 'Sidebar.delete-board', defaultMessage: 'Delete board'})}
icon={<DeleteIcon/>}
onClick={() => {
props.onDeleteRequest(board)
}}
/>
</BoardPermissionGate>
</Menu>
</MenuWrapper>
</div>
</div>
{props.isActive && !snapshot.isDragging && !props.hideViews && boardViews.map((view: BoardView) => (
<div
key={view.id}
className={`SidebarBoardItem sidebar-view-item ${view.id === currentViewId ? 'active' : ''}`}
onClick={() => props.showView(view.id, board.id)}
>
{iconForViewType(view.fields.viewType)}
<div
className='octo-sidebar-title'
title={view.title || intl.formatMessage({id: 'Sidebar.untitled-view', defaultMessage: '(Untitled View)'})}
>
{view.title || intl.formatMessage({id: 'Sidebar.untitled-view', defaultMessage: '(Untitled View)'})}
</div>
</div>
))}
</div>
))}
</>
)}
</Draggable>
)
}

View File

@ -1,5 +1,6 @@
.SidebarCategory {
margin-top: 12px;
width: calc(100% - 4px);
&:first-child {
margin-top: 0;
@ -112,6 +113,10 @@
.MenuWrapper {
display: block;
}
.badge.newCategoryBadge {
display: none;
}
}
}
@ -180,4 +185,47 @@
background: rgba(var(--sidebar-text-rgb), 0.08);
}
.badge.newCategoryBadge {
background-color: rgba(var(--sidebar-text-rgb));
color: rgb(var(--sidebar-bg-rgb));
display: flex;
width: 32px;
height: 16px;
align-items: center;
padding: 0 4px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 16px;
text-transform: uppercase;
&:hover {
display: none;
}
}
.newCategoryDragArea {
display: flex;
align-items: center;
padding: 10px;
border: 1px dashed rgba(var(--sidebar-text-rgb), 0.3);
margin: 0 16px;
border-radius: 4px;
opacity: 1;
color: rgba(var(--sidebar-text-rgb), 0.72);
font-size: 12px;
line-height: 16px;
gap: 12px;
svg {
height: 18px;
}
}
&.draggingOver {
border: 1px solid rgba(var(--sidebar-text-rgb), 1);
border-radius: 4px;
}
}

View File

@ -14,28 +14,36 @@ import configureStore from 'redux-mock-store'
import {TestBlockFactory} from '../../test/testBlockFactory'
import {wrapIntl} from '../../testUtils'
import {wrapIntl, wrapRBDNDDroppable} from '../../testUtils'
import SidebarCategory from './sidebarCategory'
describe('components/sidebarCategory', () => {
const board = TestBlockFactory.createBoard()
board.id = 'board_id'
const view = TestBlockFactory.createBoardView(board)
view.fields.sortOptions = []
const history = createMemoryHistory()
const board1 = TestBlockFactory.createBoard()
board1.id = 'board_1_id'
const board2 = TestBlockFactory.createBoard()
board2.id = 'board_2_id'
const boards = [board1, board2]
const categoryBoards1 = TestBlockFactory.createCategoryBoards()
categoryBoards1.id = 'category_1_id'
categoryBoards1.name = 'Category 1'
categoryBoards1.boardIDs = [board1.id, board2.id]
const categoryBoards2 = TestBlockFactory.createCategoryBoards()
categoryBoards2.id = 'category_2_id'
categoryBoards2.name = 'Category 2'
const categoryBoards3 = TestBlockFactory.createCategoryBoards()
categoryBoards3.id = 'category_id_3'
categoryBoards3.name = 'Category 3'
const allCategoryBoards = [
@ -80,7 +88,7 @@ describe('components/sidebarCategory', () => {
const mockStore = configureStore([])
const store = mockStore(state)
const component = wrapIntl(
const component = wrapRBDNDDroppable(wrapIntl(
<ReduxProvider store={store}>
<Router history={history}>
<SidebarCategory
@ -92,7 +100,7 @@ describe('components/sidebarCategory', () => {
/>
</Router>
</ReduxProvider>,
)
))
const {container} = render(component)
expect(container).toMatchSnapshot()
@ -107,7 +115,7 @@ describe('components/sidebarCategory', () => {
const mockStore = configureStore([])
const store = mockStore(state)
const component = wrapIntl(
const component = wrapRBDNDDroppable(wrapIntl(
<ReduxProvider store={store}>
<Router history={history}>
<SidebarCategory
@ -119,7 +127,7 @@ describe('components/sidebarCategory', () => {
/>
</Router>
</ReduxProvider>,
)
))
const {container} = render(component)
const subItems = container.querySelectorAll('.category-title')
@ -132,7 +140,7 @@ describe('components/sidebarCategory', () => {
const mockStore = configureStore([])
const store = mockStore(state)
const component = wrapIntl(
const component = wrapRBDNDDroppable(wrapIntl(
<ReduxProvider store={store}>
<Router history={history}>
<SidebarCategory
@ -145,7 +153,7 @@ describe('components/sidebarCategory', () => {
/>
</Router>
</ReduxProvider>,
)
))
const {container} = render(component)
const subItems = container.querySelectorAll('.category-title')
@ -160,7 +168,7 @@ describe('components/sidebarCategory', () => {
const mockTemplateClose = jest.fn()
const component = wrapIntl(
const component = wrapRBDNDDroppable(wrapIntl(
<ReduxProvider store={store}>
<Router history={history}>
<SidebarCategory
@ -174,7 +182,7 @@ describe('components/sidebarCategory', () => {
/>
</Router>
</ReduxProvider>,
)
))
const {container} = render(component)
expect(container).toMatchSnapshot()
@ -191,7 +199,7 @@ describe('components/sidebarCategory', () => {
const mockTemplateClose = jest.fn()
const component = wrapIntl(
const component = wrapRBDNDDroppable(wrapIntl(
<ReduxProvider store={store}>
<Router history={history}>
<SidebarCategory
@ -205,7 +213,7 @@ describe('components/sidebarCategory', () => {
/>
</Router>
</ReduxProvider>,
)
))
const {container} = render(component)
expect(container).toMatchSnapshot()

View File

@ -6,6 +6,10 @@ import {generatePath, useHistory, useRouteMatch} from 'react-router-dom'
import {debounce} from 'lodash'
import {Draggable, Droppable} from 'react-beautiful-dnd'
import {HandRightIcon} from '@mattermost/compass-icons/components'
import {Board} from '../../blocks/board'
import mutator from '../../mutator'
import IconButton from '../../widgets/buttons/iconButton'
@ -54,6 +58,8 @@ type Props = {
allCategories: CategoryBoards[]
index: number
onBoardTemplateSelectorClose?: () => void
draggedItemID?: string
forceCollapse?: boolean
}
export const ClassForManageCategoriesTourStep = 'manageCategoriesTourStep'
@ -81,6 +87,8 @@ const SidebarCategory = (props: Props) => {
const menuWrapperRef = useRef<HTMLDivElement>(null)
const [boardDraggingOver, setBoardDraggingOver] = useState<boolean>(false)
const shouldViewSidebarTour = props.boards.length !== 0 &&
noCardOpen &&
(onboardingTourCategory === TOUR_SIDEBAR || onboardingTourCategory === TOUR_BOARD) &&
@ -215,151 +223,230 @@ const SidebarCategory = (props: Props) => {
}
}
const newCategoryBadge = (
<div className='badge newCategoryBadge'>
<span>
{
intl.formatMessage({
id: 'Sidebar.new-category.badge',
defaultMessage: 'New',
})
}
</span>
</div>
)
const newCategoryDragArea = (
<div className='newCategoryDragArea'>
<HandRightIcon/>
<span>
{
intl.formatMessage({
id: 'Sidebar.new-category.drag-boards-cta',
defaultMessage: 'Drag boards here...',
})
}
</span>
</div>
)
const delayedSetBoardDraggingOver = (isDraggingOver: boolean) => {
setTimeout(() => {
setBoardDraggingOver(isDraggingOver)
}, 200)
}
return (
<div
className='SidebarCategory'
ref={menuWrapperRef}
<Draggable
draggableId={props.categoryBoards.id}
key={props.categoryBoards.id}
index={props.index}
>
<div
className={`octo-sidebar-item category ' ${collapsed ? 'collapsed' : 'expanded'} ${props.categoryBoards.id === props.activeCategoryId ? 'active' : ''}`}
>
{(provided, snapshot) => (
<div
className='octo-sidebar-title category-title'
title={props.categoryBoards.name}
onClick={toggleCollapse}
ref={provided.innerRef}
{...provided.draggableProps}
>
{collapsed ? <ChevronRight/> : <ChevronDown/>}
{props.categoryBoards.name}
<div className='sidebarCategoriesTour'>
{props.index === 0 && shouldViewSidebarTour && <SidebarCategoriesTourStep/>}
<div
className={`SidebarCategory${props.categoryBoards.isNew ? ' new' : ''}${boardDraggingOver ? ' draggingOver' : ''}`}
ref={menuWrapperRef}
>
<Droppable
droppableId={props.categoryBoards.id}
type='board'
>
{(categoryProvided, categorySnapshot) => {
if (boardDraggingOver !== categorySnapshot.isDraggingOver) {
delayedSetBoardDraggingOver(categorySnapshot.isDraggingOver)
}
return (
<div
className={`categoryBoardsDroppableArea${categorySnapshot.isDraggingOver ? ' draggingOver' : ''}`}
ref={categoryProvided.innerRef}
{...categoryProvided.droppableProps}
>
<div
className={`octo-sidebar-item category ${collapsed || props.forceCollapse ? 'collapsed' : 'expanded'} ${props.categoryBoards.id === props.activeCategoryId ? 'active' : ''}`}
>
<div
className='octo-sidebar-title category-title'
title={props.categoryBoards.name}
onClick={toggleCollapse}
{...provided.dragHandleProps}
>
{collapsed || snapshot.isDragging || props.forceCollapse ? <ChevronRight/> : <ChevronDown/>}
{props.categoryBoards.name}
<div className='sidebarCategoriesTour'>
{props.index === 0 && shouldViewSidebarTour && <SidebarCategoriesTourStep/>}
</div>
</div>
<div className={(props.index === 0 && shouldViewManageCatergoriesTour) ? `${ClassForManageCategoriesTourStep}` : ''}>
{props.index === 0 && shouldViewManageCatergoriesTour && <ManageCategoriesTourStep/>}
{props.categoryBoards.isNew && !categoryMenuOpen && newCategoryBadge}
<MenuWrapper
className={categoryMenuOpen ? 'menuOpen' : ''}
stopPropagationOnToggle={true}
onToggle={(open) => setCategoryMenuOpen(open)}
>
<IconButton icon={<OptionsIcon/>}/>
<Menu
position='auto'
parentRef={menuWrapperRef}
>
{
props.categoryBoards.type === 'custom' &&
<React.Fragment>
<Menu.Text
id='updateCategory'
name={intl.formatMessage({id: 'SidebarCategories.CategoryMenu.Update', defaultMessage: 'Rename Category'})}
icon={<CompassIcon icon='pencil-outline'/>}
onClick={handleUpdateCategory}
/>
<Menu.Text
id='deleteCategory'
className='text-danger'
name={intl.formatMessage({id: 'SidebarCategories.CategoryMenu.Delete', defaultMessage: 'Delete Category'})}
icon={<DeleteIcon/>}
onClick={() => setShowDeleteCategoryDialog(true)}
/>
<Menu.Separator/>
</React.Fragment>
}
<Menu.Text
id='createNewCategory'
name={intl.formatMessage({id: 'SidebarCategories.CategoryMenu.CreateNew', defaultMessage: 'Create New Category'})}
icon={<CreateNewFolder/>}
onClick={handleCreateNewCategory}
/>
</Menu>
</MenuWrapper>
</div>
</div>
{!(collapsed || props.forceCollapse || snapshot.isDragging || props.draggedItemID === props.categoryBoards.id) && visibleBlocks.length === 0 &&
(
<div>
{!props.categoryBoards.isNew && (
<div className='octo-sidebar-item subitem no-views'>
<FormattedMessage
id='Sidebar.no-boards-in-category'
defaultMessage='No boards inside'
/>
</div>
)}
{props.categoryBoards.isNew && newCategoryDragArea}
</div>
)
}
{!props.forceCollapse && collapsed && !snapshot.isDragging && props.draggedItemID !== props.categoryBoards.id && props.boards.filter((board: Board) => board.id === props.activeBoardID).map((board: Board, zzz) => {
if (!isBoardVisible(board.id)) {
return null
}
return (
<SidebarBoardItem
index={zzz}
key={board.id}
board={board}
categoryBoards={props.categoryBoards}
allCategories={props.allCategories}
isActive={board.id === props.activeBoardID}
showBoard={showBoard}
showView={showView}
onDeleteRequest={setDeleteBoard}
/>
)
})}
{!(collapsed || props.forceCollapse || snapshot.isDragging || props.draggedItemID === props.categoryBoards.id) && props.boards.filter((board) => isBoardVisible(board.id) && !board.isTemplate).map((board: Board, zzz) => {
return (
<SidebarBoardItem
index={zzz}
key={board.id}
board={board}
categoryBoards={props.categoryBoards}
allCategories={props.allCategories}
isActive={board.id === props.activeBoardID}
showBoard={showBoard}
showView={showView}
onDeleteRequest={setDeleteBoard}
hideViews={props.draggedItemID === board.id || props.draggedItemID === props.categoryBoards.id}
/>
)
})}
{categoryProvided.placeholder}
</div>
)
}}
</Droppable>
{
showCreateCategoryModal && (
<CreateCategory
onClose={() => setShowCreateCategoryModal(false)}
title={(
<FormattedMessage
id='SidebarCategories.CategoryMenu.CreateNew'
defaultMessage='Create New Category'
/>
)}
/>
)
}
{
showUpdateCategoryModal && (
<CreateCategory
initialValue={props.categoryBoards.name}
title={(
<FormattedMessage
id='SidebarCategories.CategoryMenu.Update'
defaultMessage='Rename Category'
/>
)}
onClose={() => setShowUpdateCategoryModal(false)}
boardCategoryId={props.categoryBoards.id}
renameModal={true}
/>
)
}
{ deleteBoard &&
<DeleteBoardDialog
boardTitle={deleteBoard.title}
onClose={() => setDeleteBoard(null)}
onDelete={onDeleteBoard}
/>
}
{
showDeleteCategoryDialog && <ConfirmationDialogBox dialogBox={deleteCategoryProps}/>
}
</div>
</div>
<div className={(props.index === 0 && shouldViewManageCatergoriesTour) ? `${ClassForManageCategoriesTourStep}` : ''}>
{props.index === 0 && shouldViewManageCatergoriesTour && <ManageCategoriesTourStep/>}
<MenuWrapper
className={categoryMenuOpen ? 'menuOpen' : ''}
stopPropagationOnToggle={true}
onToggle={(open) => setCategoryMenuOpen(open)}
>
<IconButton icon={<OptionsIcon/>}/>
<Menu
position='auto'
parentRef={menuWrapperRef}
>
{
props.categoryBoards.type === 'custom' &&
<React.Fragment>
<Menu.Text
id='updateCategory'
name={intl.formatMessage({id: 'SidebarCategories.CategoryMenu.Update', defaultMessage: 'Rename Category'})}
icon={<CompassIcon icon='pencil-outline'/>}
onClick={handleUpdateCategory}
/>
<Menu.Text
id='deleteCategory'
className='text-danger'
name={intl.formatMessage({id: 'SidebarCategories.CategoryMenu.Delete', defaultMessage: 'Delete Category'})}
icon={<DeleteIcon/>}
onClick={() => setShowDeleteCategoryDialog(true)}
/>
<Menu.Separator/>
</React.Fragment>
}
<Menu.Text
id='createNewCategory'
name={intl.formatMessage({id: 'SidebarCategories.CategoryMenu.CreateNew', defaultMessage: 'Create New Category'})}
icon={<CreateNewFolder/>}
onClick={handleCreateNewCategory}
/>
</Menu>
</MenuWrapper>
</div>
</div>
{!collapsed && visibleBlocks.length === 0 &&
<div className='octo-sidebar-item subitem no-views'>
<FormattedMessage
id='Sidebar.no-boards-in-category'
defaultMessage='No boards inside'
/>
</div>}
{collapsed && props.boards.filter((board: Board) => board.id === props.activeBoardID).map((board: Board) => {
if (!isBoardVisible(board.id)) {
return null
}
return (
<SidebarBoardItem
key={board.id}
board={board}
categoryBoards={props.categoryBoards}
allCategories={props.allCategories}
isActive={board.id === props.activeBoardID}
showBoard={showBoard}
showView={showView}
onDeleteRequest={setDeleteBoard}
/>
)
})}
{!collapsed && props.boards.filter((board) => !board.isTemplate).map((board: Board) => {
if (!isBoardVisible(board.id)) {
return null
}
return (
<SidebarBoardItem
key={board.id}
board={board}
categoryBoards={props.categoryBoards}
allCategories={props.allCategories}
isActive={board.id === props.activeBoardID}
showBoard={showBoard}
showView={showView}
onDeleteRequest={setDeleteBoard}
/>
)
})}
{
showCreateCategoryModal && (
<CreateCategory
onClose={() => setShowCreateCategoryModal(false)}
title={(
<FormattedMessage
id='SidebarCategories.CategoryMenu.CreateNew'
defaultMessage='Create New Category'
/>
)}
/>
)
}
{
showUpdateCategoryModal && (
<CreateCategory
initialValue={props.categoryBoards.name}
title={(
<FormattedMessage
id='SidebarCategories.CategoryMenu.Update'
defaultMessage='Rename Category'
/>
)}
onClose={() => setShowUpdateCategoryModal(false)}
boardCategoryId={props.categoryBoards.id}
renameModal={true}
/>
)
}
{ deleteBoard &&
<DeleteBoardDialog
boardTitle={deleteBoard.title}
onClose={() => setDeleteBoard(null)}
onDelete={onDeleteBoard}
/>
}
{
showDeleteCategoryDialog && <ConfirmationDialogBox dialogBox={deleteCategoryProps}/>
}
</div>
)}
</Draggable>
)
}

View File

@ -62,4 +62,9 @@
.URLProperty:hover .Button_Copy {
display: none;
}
.octo-propertyvalue--readonly {
flex-wrap: nowrap;
overflow: hidden !important;
}
}

View File

@ -1013,7 +1013,7 @@ exports[`components/viewHeader/filterComponent return filterComponent and filter
type="button"
>
<span>
includes
(unknown)
</span>
</button>
</div>

View File

@ -1499,7 +1499,7 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on statu
type="button"
>
<span>
includes
(unknown)
</span>
</button>
</div>

View File

@ -84,7 +84,7 @@ const FilterEntry = (props: Props): JSX.Element => {
</Menu>
</MenuWrapper>
<MenuWrapper>
<Button>{OctoUtils.filterConditionDisplayString(filter.condition, intl)}</Button>
<Button>{OctoUtils.filterConditionDisplayString(filter.condition, intl, propertyType.filterValueType)}</Button>
<Menu>
{propertyType.filterValueType === 'options' &&
<>

View File

@ -4,7 +4,7 @@
flex: 1 1 auto;
display: flex;
flex-direction: row;
overflow: auto;
overflow: hidden;
position: relative;
> .mainFrame {

View File

@ -835,6 +835,38 @@ class OctoClient {
})
}
async reorderSidebarCategories(teamID: string, newCategoryOrder: string[]): Promise<string[]> {
const path = `/api/v2/teams/${teamID}/categories/reorder`
const body = JSON.stringify(newCategoryOrder)
const response = await fetch(this.getBaseURL() + path, {
method: 'PUT',
headers: this.headers(),
body,
})
if (response.status !== 200) {
return []
}
return (await this.getJson(response, [])) as string[]
}
async reorderSidebarCategoryBoards(teamID: string, categoryID: string, newBoardsOrder: string[]): Promise<string[]> {
const path = `/api/v2/teams/${teamID}/categories/${categoryID}/boards/reorder`
const body = JSON.stringify(newBoardsOrder)
const response = await fetch(this.getBaseURL() + path, {
method: 'PUT',
headers: this.headers(),
body,
})
if (response.status !== 200) {
return []
}
return (await this.getJson(response, [])) as string[]
}
async moveBoardToCategory(teamID: string, boardID: string, toCategoryID: string, fromCategoryID: string): Promise<Response> {
const url = `/api/v2/teams/${teamID}/categories/${toCategoryID || '0'}/boards/${boardID}`
const payload = {

View File

@ -104,26 +104,42 @@ class OctoUtils {
return [newBlocks, newSourceBlock, idMap]
}
static filterConditionDisplayString(filterCondition: FilterCondition, intl: IntlShape): string {
switch (filterCondition) {
case 'includes': return intl.formatMessage({id: 'Filter.includes', defaultMessage: 'includes'})
case 'notIncludes': return intl.formatMessage({id: 'Filter.not-includes', defaultMessage: 'doesn\'t include'})
case 'isEmpty': return intl.formatMessage({id: 'Filter.is-empty', defaultMessage: 'is empty'})
case 'isNotEmpty': return intl.formatMessage({id: 'Filter.is-not-empty', defaultMessage: 'is not empty'})
case 'isSet': return intl.formatMessage({id: 'Filter.is-set', defaultMessage: 'is set'})
case 'isNotSet': return intl.formatMessage({id: 'Filter.is-not-set', defaultMessage: 'is not set'})
case 'is': return intl.formatMessage({id: 'Filter.is', defaultMessage: 'is'})
case 'contains': return intl.formatMessage({id: 'Filter.contains', defaultMessage: 'contains'})
case 'notContains': return intl.formatMessage({id: 'Filter.not-contains', defaultMessage: 'doesn\'t contain'})
case 'startsWith': return intl.formatMessage({id: 'Filter.starts-with', defaultMessage: 'starts with'})
case 'notStartsWith': return intl.formatMessage({id: 'Filter.not-starts-with', defaultMessage: 'doesn\'t start with'})
case 'endsWith': return intl.formatMessage({id: 'Filter.ends-with', defaultMessage: 'ends with'})
case 'notEndsWith': return intl.formatMessage({id: 'Filter.not-ends-with', defaultMessage: 'doesn\'t end with'})
default: {
static filterConditionDisplayString(filterCondition: FilterCondition, intl: IntlShape, filterValueType: string): string {
if (filterValueType === 'options') {
switch (filterCondition) {
case 'includes': return intl.formatMessage({id: 'Filter.includes', defaultMessage: 'includes'})
case 'notIncludes': return intl.formatMessage({id: 'Filter.not-includes', defaultMessage: 'doesn\'t include'})
case 'isEmpty': return intl.formatMessage({id: 'Filter.is-empty', defaultMessage: 'is empty'})
case 'isNotEmpty': return intl.formatMessage({id: 'Filter.is-not-empty', defaultMessage: 'is not empty'})
default: {
return intl.formatMessage({id: 'Filter.includes', defaultMessage: 'includes'})
}
}
} else if (filterValueType === 'boolean') {
switch (filterCondition) {
case 'isSet': return intl.formatMessage({id: 'Filter.is-set', defaultMessage: 'is set'})
case 'isNotSet': return intl.formatMessage({id: 'Filter.is-not-set', defaultMessage: 'is not set'})
default: {
return intl.formatMessage({id: 'Filter.is-set', defaultMessage: 'is set'})
}
}
} else if (filterValueType === 'text') {
switch (filterCondition) {
case 'is': return intl.formatMessage({id: 'Filter.is', defaultMessage: 'is'})
case 'contains': return intl.formatMessage({id: 'Filter.contains', defaultMessage: 'contains'})
case 'notContains': return intl.formatMessage({id: 'Filter.not-contains', defaultMessage: 'doesn\'t contain'})
case 'startsWith': return intl.formatMessage({id: 'Filter.starts-with', defaultMessage: 'starts with'})
case 'notStartsWith': return intl.formatMessage({id: 'Filter.not-starts-with', defaultMessage: 'doesn\'t start with'})
case 'endsWith': return intl.formatMessage({id: 'Filter.ends-with', defaultMessage: 'ends with'})
case 'notEndsWith': return intl.formatMessage({id: 'Filter.not-ends-with', defaultMessage: 'doesn\'t end with'})
default: {
return intl.formatMessage({id: 'Filter.is', defaultMessage: 'is'})
}
}
} else {
Utils.assertFailure()
return '(unknown)'
}
}
}
}

View File

@ -299,14 +299,14 @@ function sortCards(cards: Card[], lastCommentByCard: {[key: string]: CommentBloc
}
if (template.type === 'multiPerson') {
aValue = Array.isArray(aValue) && aValue.length !== 0 && usersById !== {} ? aValue.map((id) => {
aValue = Array.isArray(aValue) && aValue.length !== 0 && Object.keys(usersById).length > 0 ? aValue.map((id) => {
if (usersById[id] !== undefined) {
return usersById[id].username
}
return ''
}).toString() : aValue
bValue = Array.isArray(bValue) && bValue.length !== 0 && usersById !== {} ? bValue.map((id) => {
bValue = Array.isArray(bValue) && bValue.length !== 0 && Object.keys(usersById).length > 0 ? bValue.map((id) => {
if (usersById[id] !== undefined) {
return usersById[id].username
}

View File

@ -5,6 +5,8 @@ import {createAsyncThunk, createSelector, createSlice, PayloadAction} from '@red
import {default as client} from '../octoClient'
import {Utils} from '../utils'
import {RootState} from './index'
export type CategoryType = 'system' | 'custom'
@ -18,7 +20,9 @@ interface Category {
updateAt: number
deleteAt: number
collapsed: boolean
sortOrder: number
type: CategoryType
isNew: boolean
}
interface CategoryBoards extends Category {
@ -30,6 +34,11 @@ interface BoardCategoryWebsocketData {
categoryID: string
}
interface CategoryBoardsReorderData {
categoryID: string
boardIDs: string[]
}
export const DefaultCategory: CategoryBoards = {
id: '',
name: 'Boards',
@ -38,14 +47,7 @@ export const DefaultCategory: CategoryBoards = {
export const fetchSidebarCategories = createAsyncThunk(
'sidebarCategories/fetch',
async (teamID: string) => {
// TODO All this logic should remove once LHS DND PR gets merged
const allCategories = await client.getSidebarCategories(teamID)
const boardSystemCategoies = allCategories.filter((category) => category.name === 'Boards' && category.type === 'system')
const categoriesWithoutSystemBoard = allCategories.filter((category) => category.name !== 'Boards' && category.type !== 'system')
const categories = categoriesWithoutSystemBoard
categories.sort((a, b) => a.name.localeCompare(b.name))
categories.push(boardSystemCategoies[0])
return categories
return client.getSidebarCategories(teamID)
},
)
@ -61,11 +63,13 @@ const sidebarSlice = createSlice({
action.payload.forEach((updatedCategory) => {
const index = state.categoryAttributes.findIndex((c) => c.id === updatedCategory.id)
// when new category got created
// when new category got created,
if (index === -1) {
state.categoryAttributes.push({
// new categories should always show up on the top
state.categoryAttributes.unshift({
...updatedCategory,
boardIDs: [],
isNew: true,
})
} else if (updatedCategory.deleteAt) {
// when category is deleted
@ -76,33 +80,79 @@ const sidebarSlice = createSlice({
...state.categoryAttributes[index],
name: updatedCategory.name,
updateAt: updatedCategory.updateAt,
isNew: false,
}
}
})
// sort categories alphabetically only keeping board system category at the end
// TODO All this logic should remove once LHS DND PR gets merged
const boardsSystemCategories = state.categoryAttributes.filter((category) => category.name === 'Boards' && category.type === 'system')
const categoriesWithoutSystemBoard = state.categoryAttributes.filter((category) => category.name !== 'Baords' && category.type !== 'system')
const categories = categoriesWithoutSystemBoard
categories.sort((a, b) => a.name.localeCompare(b.name))
categories.push(boardsSystemCategories[0])
state.categoryAttributes = categories
},
updateBoardCategories: (state, action: PayloadAction<BoardCategoryWebsocketData[]>) => {
const updatedCategoryAttributes: CategoryBoards[] = []
action.payload.forEach((boardCategory) => {
for (let i = 0; i < state.categoryAttributes.length; i++) {
const categoryAttribute = state.categoryAttributes[i]
// first we remove the board from list of boards
categoryAttribute.boardIDs = categoryAttribute.boardIDs.filter((boardID) => boardID !== boardCategory.boardID)
// then we add it if this is the target category
if (categoryAttribute.id === boardCategory.categoryID) {
categoryAttribute.boardIDs.push(boardCategory.boardID)
// 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)
categoryAttribute.isNew = false
}
} else {
// remove the board from other categories
categoryAttribute.boardIDs = categoryAttribute.boardIDs.filter((boardID) => boardID !== boardCategory.boardID)
}
updatedCategoryAttributes[i] = categoryAttribute
}
})
if (updatedCategoryAttributes.length > 0) {
state.categoryAttributes = updatedCategoryAttributes
}
},
updateCategoryOrder: (state, action: PayloadAction<string[]>) => {
if (action.payload.length === 0) {
return
}
const categoryById = new Map<string, CategoryBoards>()
state.categoryAttributes.forEach((categoryBoards: CategoryBoards) => categoryById.set(categoryBoards.id, categoryBoards))
const newOrderedCategories: CategoryBoards[] = []
action.payload.forEach((categoryId) => {
const category = categoryById.get(categoryId)
if (!category) {
Utils.logError('Category ID from updated category order not found in store. CategoryID: ' + categoryId)
return
}
newOrderedCategories.push(category)
})
state.categoryAttributes = newOrderedCategories
},
updateCategoryBoardsOrder: (state, action: PayloadAction<CategoryBoardsReorderData>) => {
if (action.payload.boardIDs.length === 0) {
return
}
const categoryIndex = state.categoryAttributes.findIndex((categoryBoards) => categoryBoards.id === action.payload.categoryID)
if (categoryIndex < 0) {
Utils.logError('Category ID from updated category boards order not found in store. CategoryID: ' + action.payload.categoryID)
return
}
const category = state.categoryAttributes[categoryIndex]
const updatedCategory = {
...category,
boardIDs: action.payload.boardIDs,
isNew: false,
}
// creating a new reference of array so redux knows it changed
state.categoryAttributes = state.categoryAttributes.map((original, i) => (i === categoryIndex ? updatedCategory : original))
},
},
extraReducers: (builder) => {
@ -119,7 +169,7 @@ export const getSidebarCategories = createSelector(
export const {reducer} = sidebarSlice
export const {updateCategories, updateBoardCategories} = sidebarSlice.actions
export const {updateCategories, updateBoardCategories, updateCategoryOrder, updateCategoryBoardsOrder} = sidebarSlice.actions
export {Category, CategoryBoards, BoardCategoryWebsocketData}
export {Category, CategoryBoards, BoardCategoryWebsocketData, CategoryBoardsReorderData}

View File

@ -102,7 +102,7 @@ html {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: auto;
overflow: hidden;
}
/* Main app */

View File

@ -174,6 +174,8 @@ class TestBlockFactory {
teamID: '',
collapsed: false,
type: 'custom',
sortOrder: 0,
isNew: false,
}
}

View File

@ -7,6 +7,8 @@ import {HTML5Backend} from 'react-dnd-html5-backend'
import configureStore, {MockStoreEnhanced} from 'redux-mock-store'
import {Middleware} from 'redux'
import {DragDropContext, Droppable} from 'react-beautiful-dnd'
import {Block} from './blocks/block'
export const wrapIntl = (children?: React.ReactNode): JSX.Element => <IntlProvider locale='en'>{children}</IntlProvider>
@ -18,6 +20,31 @@ export const wrapDNDIntl = (children?: React.ReactNode): JSX.Element => {
)
}
export const wrapRBDNDContext = (children?: React.ReactNode): JSX.Element => {
return (
<DragDropContext onDragEnd={() => {}}>
{children}
</DragDropContext>
)
}
export const wrapRBDNDDroppable = (children?: React.ReactNode): JSX.Element => {
const draggableComponent = (
<Droppable droppableId='droppable_id'>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
>
{children}
</div>
)}
</Droppable>
)
return wrapRBDNDContext(draggableComponent)
}
export function mockDOM(): void {
window.focus = jest.fn()
document.createRange = () => {

View File

@ -31,8 +31,9 @@ const base32Alphabet = 'ybndrfg8ejkmcpqxot1uwisza345h769'
export const SYSTEM_ADMIN_ROLE = 'system_admin'
export const TEAM_ADMIN_ROLE = 'team_admin'
export type CategoryOrder = string[]
export type WSMessagePayloads = Block | Category | BoardCategoryWebsocketData | BoardType | BoardMember | null
export type WSMessagePayloads = Block | Category | BoardCategoryWebsocketData[] | BoardType | BoardMember | null | CategoryOrder
// eslint-disable-next-line no-shadow
enum IDType {
@ -627,6 +628,8 @@ class Utils {
return [message.blockCategories, 'blockCategories']
} else if (message.member) {
return [message.member, 'boardMembers']
} else if (message.categoryOrder) {
return [message.categoryOrder, 'categoryOrder']
}
return [null, 'block']
}

View File

@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import CompassIcon from './compassIcon'
export default function HandRight(): JSX.Element {
return (
<CompassIcon
icon='hand-right'
className='HandRightIcon'
/>
)
}

View File

@ -3,7 +3,7 @@
import {ClientConfig} from './config/clientConfig'
import {Utils, WSMessagePayloads} from './utils'
import {CategoryOrder, Utils, WSMessagePayloads} from './utils'
import {Block} from './blocks/block'
import {Board, BoardMember} from './blocks/board'
import {OctoUtils} from './octoUtils'
@ -23,11 +23,12 @@ export type WSMessage = {
block?: Block
board?: Board
category?: Category
blockCategories?: BoardCategoryWebsocketData
blockCategories?: BoardCategoryWebsocketData[]
error?: string
teamId?: string
member?: BoardMember
timestamp?: number
categoryOrder?: string[]
}
export const ACTION_UPDATE_BOARD = 'UPDATE_BOARD'
@ -44,6 +45,7 @@ export const ACTION_UPDATE_CATEGORY = 'UPDATE_CATEGORY'
export const ACTION_UPDATE_BOARD_CATEGORY = 'UPDATE_BOARD_CATEGORY'
export const ACTION_UPDATE_SUBSCRIPTION = 'UPDATE_SUBSCRIPTION'
export const ACTION_UPDATE_CARD_LIMIT_TIMESTAMP = 'UPDATE_CARD_LIMIT_TIMESTAMP'
export const ACTION_REORDER_CATEGORIES = 'REORDER_CATEGORIES'
type WSSubscriptionMsg = {
action?: string
@ -79,7 +81,7 @@ type OnConfigChangeHandler = (client: WSClient, clientConfig: ClientConfig) => v
type OnCardLimitTimestampChangeHandler = (client: WSClient, timestamp: number) => void
type FollowChangeHandler = (client: WSClient, subscription: Subscription) => void
export type ChangeHandlerType = 'block' | 'category' | 'blockCategories' | 'board' | 'boardMembers'
export type ChangeHandlerType = 'block' | 'category' | 'blockCategories' | 'board' | 'boardMembers' | 'categoryOrder'
type UpdatedData = {
Blocks: Block[]
@ -87,6 +89,7 @@ type UpdatedData = {
BoardCategories: BoardCategoryWebsocketData[]
Boards: Board[]
BoardMembers: BoardMember[]
CategoryOrder: string[]
}
type ChangeHandlers = {
@ -95,6 +98,7 @@ type ChangeHandlers = {
BoardCategory: OnChangeHandler[]
Board: OnChangeHandler[]
BoardMember: OnChangeHandler[]
CategoryReorder: OnChangeHandler[]
}
type Subscriptions = {
@ -115,7 +119,7 @@ class WSClient {
state: 'init'|'open'|'close' = 'init'
onStateChange: OnStateChangeHandler[] = []
onReconnect: OnReconnectHandler[] = []
onChange: ChangeHandlers = {Block: [], Category: [], BoardCategory: [], Board: [], BoardMember: []}
onChange: ChangeHandlers = {Block: [], Category: [], BoardCategory: [], Board: [], BoardMember: [], CategoryReorder: []}
onError: OnErrorHandler[] = []
onConfigChange: OnConfigChangeHandler[] = []
onCardLimitTimestampChange: OnCardLimitTimestampChangeHandler[] = []
@ -123,7 +127,7 @@ class WSClient {
onUnfollowBlock: FollowChangeHandler = () => {}
private notificationDelay = 100
private reopenDelay = 3000
private updatedData: UpdatedData = {Blocks: [], Categories: [], BoardCategories: [], Boards: [], BoardMembers: []}
private updatedData: UpdatedData = {Blocks: [], Categories: [], BoardCategories: [], Boards: [], BoardMembers: [], CategoryOrder: []}
private updateTimeout?: NodeJS.Timeout
private errorPollId?: NodeJS.Timeout
private subscriptions: Subscriptions = {Teams: {}}
@ -226,6 +230,9 @@ class WSClient {
case 'boardMembers':
this.onChange.BoardMember.push(handler)
break
case 'categoryOrder':
this.onChange.CategoryReorder.push(handler)
break
}
}
@ -247,6 +254,9 @@ class WSClient {
case 'category':
haystack = this.onChange.Category
break
case 'categoryOrder':
haystack = this.onChange.CategoryReorder
break
}
if (!haystack) {
@ -467,6 +477,9 @@ class WSClient {
case ACTION_UPDATE_SUBSCRIPTION:
this.updateSubscriptionHandler(message)
break
case ACTION_REORDER_CATEGORIES:
this.updateHandler(message)
break
default:
Utils.logError(`Unexpected action: ${message.action}`)
}
@ -656,14 +669,20 @@ class WSClient {
this.updatedData.Categories = this.updatedData.Categories.filter((c) => c.id !== (data as Category).id)
this.updatedData.Categories.push(data as Category)
} else if (type === 'blockCategories') {
this.updatedData.BoardCategories = this.updatedData.BoardCategories.filter((b) => b.boardID === (data as BoardCategoryWebsocketData).boardID)
this.updatedData.BoardCategories.push(data as BoardCategoryWebsocketData)
this.updatedData.BoardCategories = this.updatedData.BoardCategories.filter((b) => !(data as BoardCategoryWebsocketData[]).find((boardCategory) => boardCategory.boardID === b.boardID))
this.updatedData.BoardCategories.push(...(data as BoardCategoryWebsocketData[]))
} else if (type === 'board') {
this.updatedData.Boards = this.updatedData.Boards.filter((b) => b.id !== (data as Board).id)
this.updatedData.Boards.push(data as Board)
} else if (type === 'boardMembers') {
this.updatedData.BoardMembers = this.updatedData.BoardMembers.filter((m) => m.userId !== (data as BoardMember).userId || m.boardId !== (data as BoardMember).boardId)
this.updatedData.BoardMembers.push(data as BoardMember)
} else if (type === 'categoryOrder') {
// Since each update contains the whole state of all
// categories, we don't need to keep accumulating all updates.
// Only the very latest one is sufficient to describe the
// latest state of all sidebar categories.
this.updatedData.CategoryOrder = (data as CategoryOrder)
}
if (this.updateTimeout) {
@ -696,6 +715,8 @@ class WSClient {
for (const boardMember of this.updatedData.BoardMembers) {
Utils.log(`WSClient flush update boardMember: ${boardMember.userId} ${boardMember.boardId}`)
}
Utils.log(`WSClient flush update categoryOrder: ${this.updatedData.CategoryOrder}`)
}
private flushUpdateNotifications() {
@ -721,12 +742,17 @@ class WSClient {
handler(this, this.updatedData.BoardMembers)
}
for (const handler of this.onChange.CategoryReorder) {
handler(this, this.updatedData.CategoryOrder)
}
this.updatedData = {
Blocks: [],
Categories: [],
BoardCategories: [],
Boards: [],
BoardMembers: [],
CategoryOrder: [],
}
}
@ -740,7 +766,7 @@ class WSClient {
// Use this sequence so the onclose method doesn't try to re-open
const ws = this.ws
this.ws = null
this.onChange = {Block: [], Category: [], BoardCategory: [], Board: [], BoardMember: []}
this.onChange = {Block: [], Category: [], BoardCategory: [], Board: [], BoardMember: [], CategoryReorder: []}
this.onReconnect = []
this.onStateChange = []
this.onError = []