mirror of
https://github.com/mattermost/focalboard.git
synced 2024-12-24 13:43:12 +02:00
Merge branch 'main' into only-explicit-boards-on-default-category
This commit is contained in:
commit
7db7e56296
@ -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) => {
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -52,7 +52,7 @@ func TestAddMemberToBoard(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", "board_id_1").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "default_category_id"}).Return(nil)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
@ -126,7 +126,7 @@ func TestAddMemberToBoard(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", "board_id_1").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "default_category_id"}).Return(nil)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
@ -434,9 +434,11 @@ func TestBoardCategory(t *testing.T) {
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().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().AddUpdateCategoryBoard("user_id", map[string]string{
|
||||
"board_id_1": "default_category_id",
|
||||
"board_id_2": "default_category_id",
|
||||
"board_id_3": "default_category_id",
|
||||
}).Return(nil)
|
||||
|
||||
boards := []*model.Board{
|
||||
{ID: "board_id_1"},
|
||||
@ -449,3 +451,55 @@ func TestBoardCategory(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDuplicateBoard(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
board := &model.Board{
|
||||
ID: "board_id_2",
|
||||
Title: "Duplicated Board",
|
||||
}
|
||||
|
||||
block := &model.Block{
|
||||
ID: "block_id_1",
|
||||
Type: "image",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false).Return(
|
||||
&model.BoardsAndBlocks{
|
||||
Boards: []*model.Board{
|
||||
board,
|
||||
},
|
||||
Blocks: []*model.Block{
|
||||
block,
|
||||
},
|
||||
},
|
||||
[]*model.BoardMember{},
|
||||
nil,
|
||||
)
|
||||
|
||||
th.Store.EXPECT().GetBoard("board_id_1").Return(&model.Board{}, nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
},
|
||||
}, nil).Times(2)
|
||||
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", utils.Anything).Return(nil)
|
||||
|
||||
// for WS change broadcast
|
||||
th.Store.EXPECT().GetMembersForBoard(utils.Anything).Return([]*model.BoardMember{}, nil).Times(2)
|
||||
|
||||
bab, members, err := th.App.DuplicateBoard("board_id_1", "user_id_1", "team_id_1", false)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, bab)
|
||||
assert.NotNil(t, members)
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
@ -8,6 +9,9 @@ import (
|
||||
|
||||
const defaultCategoryBoards = "Boards"
|
||||
|
||||
var errCategoryBoardsLengthMismatch = errors.New("cannot update category boards order, passed list of categories boards different size than in database")
|
||||
var errBoardNotFoundInCategory = errors.New("specified board ID not found in specified category ID")
|
||||
|
||||
func (a *App) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) {
|
||||
categoryBoards, err := a.store.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
@ -53,6 +57,7 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
|
||||
TeamID: teamID,
|
||||
Collapsed: false,
|
||||
Type: model.CategoryTypeSystem,
|
||||
SortOrder: len(existingCategoryBoards) * model.CategoryBoardsSortOrderGap,
|
||||
}
|
||||
createdCategory, err := a.CreateCategory(&category)
|
||||
if err != nil {
|
||||
@ -99,7 +104,7 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
|
||||
}
|
||||
|
||||
if !belongsToCategory {
|
||||
if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, bm.BoardID); err != nil {
|
||||
if err := a.AddUpdateUserCategoryBoard(teamID, userID, map[string]string{bm.BoardID: 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)
|
||||
}
|
||||
|
||||
@ -110,22 +115,100 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
|
||||
return createdCategoryBoards, nil
|
||||
}
|
||||
|
||||
func (a *App) AddUpdateUserCategoryBoard(teamID, userID, categoryID, boardID string) error {
|
||||
err := a.store.AddUpdateCategoryBoard(userID, categoryID, boardID)
|
||||
func (a *App) AddUpdateUserCategoryBoard(teamID, userID string, boardCategoryMapping map[string]string) error {
|
||||
err := a.store.AddUpdateCategoryBoard(userID, boardCategoryMapping)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wsPayload := make([]*model.BoardCategoryWebsocketData, len(boardCategoryMapping))
|
||||
i := 0
|
||||
for boardID, categoryID := range boardCategoryMapping {
|
||||
wsPayload[i] = &model.BoardCategoryWebsocketData{
|
||||
BoardID: boardID,
|
||||
CategoryID: categoryID,
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastCategoryBoardChange(
|
||||
teamID,
|
||||
userID,
|
||||
model.BoardCategoryWebsocketData{
|
||||
BoardID: boardID,
|
||||
CategoryID: categoryID,
|
||||
})
|
||||
wsPayload,
|
||||
)
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ReorderCategoryBoards(userID, teamID, categoryID string, newBoardsOrder []string) ([]string, error) {
|
||||
if err := a.verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID, newBoardsOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newOrder, err := a.store.ReorderCategoryBoards(categoryID, newBoardsOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryBoardsReorder(teamID, userID, categoryID, newOrder)
|
||||
}()
|
||||
|
||||
return newOrder, nil
|
||||
}
|
||||
|
||||
func (a *App) verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID string, newBoardsOrder []string) error {
|
||||
// this function is to ensure that we don't miss specifying
|
||||
// all boards of the category while reordering.
|
||||
existingCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var targetCategoryBoards *model.CategoryBoards
|
||||
for i := range existingCategoryBoards {
|
||||
if existingCategoryBoards[i].Category.ID == categoryID {
|
||||
targetCategoryBoards = &existingCategoryBoards[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetCategoryBoards == nil {
|
||||
return fmt.Errorf("%w categoryID: %s", errCategoryNotFound, categoryID)
|
||||
}
|
||||
|
||||
if len(targetCategoryBoards.BoardIDs) != len(newBoardsOrder) {
|
||||
return fmt.Errorf(
|
||||
"%w length new category boards: %d, length existing category boards: %d, userID: %s, teamID: %s, categoryID: %s",
|
||||
errCategoryBoardsLengthMismatch,
|
||||
len(newBoardsOrder),
|
||||
len(targetCategoryBoards.BoardIDs),
|
||||
userID,
|
||||
teamID,
|
||||
categoryID,
|
||||
)
|
||||
}
|
||||
|
||||
existingBoardMap := map[string]bool{}
|
||||
for _, boardID := range targetCategoryBoards.BoardIDs {
|
||||
existingBoardMap[boardID] = true
|
||||
}
|
||||
|
||||
for _, boardID := range newBoardsOrder {
|
||||
if _, found := existingBoardMap[boardID]; !found {
|
||||
return fmt.Errorf(
|
||||
"%w board ID: %s, category ID: %s, userID: %s, teamID: %s",
|
||||
errBoardNotFoundInCategory,
|
||||
boardID,
|
||||
categoryID,
|
||||
userID,
|
||||
teamID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -35,9 +35,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)
|
||||
@ -157,9 +157,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)
|
||||
@ -193,7 +193,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)
|
||||
@ -207,3 +207,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)
|
||||
})
|
||||
}
|
||||
|
@ -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,170 @@ 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().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)
|
||||
})
|
||||
}
|
||||
|
@ -56,7 +56,7 @@ func TestApp_ImportArchive(t *testing.T) {
|
||||
Name: "Boards",
|
||||
}, 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")
|
||||
|
@ -77,7 +77,7 @@ 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().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_2": "boards_category_id"}).Return(nil)
|
||||
|
||||
teamID, boardID, err := th.App.PrepareOnboardingTour(userID, teamID)
|
||||
assert.NoError(t, err)
|
||||
@ -120,7 +120,7 @@ func TestCreateWelcomeBoard(t *testing.T) {
|
||||
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
|
||||
},
|
||||
}, nil).Times(2)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", "board_id_1").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
|
||||
|
||||
boardID, err := th.App.createWelcomeBoard(userID, teamID)
|
||||
assert.Nil(t, err)
|
||||
|
@ -442,6 +442,15 @@ func (c *Client) CreateCategory(category model.Category) (*model.Category, *Resp
|
||||
return model.CategoryFromJSON(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteCategory(teamID, categoryID string) *Response {
|
||||
r, err := c.DoAPIDelete(c.GetTeamRoute(teamID)+"/categories/"+categoryID, "")
|
||||
if err != nil {
|
||||
return BuildErrorResponse(r, err)
|
||||
}
|
||||
|
||||
return BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateCategoryBoard(teamID, categoryID, boardID string) *Response {
|
||||
r, err := c.DoAPIPost(fmt.Sprintf("%s/categories/%s/boards/%s", c.GetTeamRoute(teamID), categoryID, boardID), "")
|
||||
if err != nil {
|
||||
@ -464,6 +473,30 @@ func (c *Client) GetUserCategoryBoards(teamID string) ([]model.CategoryBoards, *
|
||||
return categoryBoards, BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) ReorderCategories(teamID string, newOrder []string) ([]string, *Response) {
|
||||
r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/reorder", toJSON(newOrder))
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
var updatedCategoryOrder []string
|
||||
_ = json.NewDecoder(r.Body).Decode(&updatedCategoryOrder)
|
||||
return updatedCategoryOrder, BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) ReorderCategoryBoards(teamID, categoryID string, newOrder []string) ([]string, *Response) {
|
||||
r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/"+categoryID+"/reorder", toJSON(newOrder))
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
var updatedBoardsOrder []string
|
||||
_ = json.NewDecoder(r.Body).Decode(&updatedBoardsOrder)
|
||||
return updatedBoardsOrder, BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks) (*model.BoardsAndBlocks, *Response) {
|
||||
r, err := c.DoAPIPatch(c.GetBoardsAndBlocksRoute(), toJSON(pbab))
|
||||
if err != nil {
|
||||
|
@ -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)
|
||||
}
|
||||
|
54
server/integrationtests/sidebar_test.go
Normal file
54
server/integrationtests/sidebar_test.go
Normal 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)
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -0,0 +1 @@
|
||||
SELECT 1;
|
@ -0,0 +1 @@
|
||||
ALTER TABLE {{.prefix}}categories ADD COLUMN sort_order BIGINT DEFAULT 0;
|
@ -0,0 +1 @@
|
||||
SELECT 1;
|
@ -0,0 +1 @@
|
||||
ALTER TABLE {{.prefix}}category_boards ADD COLUMN sort_order BIGINT DEFAULT 0;
|
@ -0,0 +1 @@
|
||||
SELECT 1;
|
@ -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}}
|
@ -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)
|
||||
|
@ -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"
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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])
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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()),
|
||||
)
|
||||
|
||||
|
@ -244,6 +244,8 @@
|
||||
"Sidebar.template-from-board": "New template from board",
|
||||
"Sidebar.untitled-board": "(Untitled Board)",
|
||||
"Sidebar.untitled-view": "(Untitled View)",
|
||||
"Sidebar.new-category.badge": "New",
|
||||
"Sidebar.new-category.drag-boards-cta": "Drag boards here...",
|
||||
"SidebarCategories.BlocksMenu.Move": "Move To...",
|
||||
"SidebarCategories.CategoryMenu.CreateNew": "Create New Category",
|
||||
"SidebarCategories.CategoryMenu.Delete": "Delete Category",
|
||||
|
92
webapp/package-lock.json
generated
92
webapp/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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]
|
||||
|
||||
|
@ -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'/>
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -62,4 +62,9 @@
|
||||
.URLProperty:hover .Button_Copy {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.octo-propertyvalue--readonly {
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
}
|
||||
|
@ -1013,7 +1013,7 @@ exports[`components/viewHeader/filterComponent return filterComponent and filter
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
includes
|
||||
(unknown)
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -1499,7 +1499,7 @@ exports[`components/viewHeader/filterEntry return filterEntry and click on statu
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
includes
|
||||
(unknown)
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -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' &&
|
||||
<>
|
||||
|
@ -4,7 +4,7 @@
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
> .mainFrame {
|
||||
|
@ -796,6 +796,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 = {
|
||||
|
@ -102,26 +102,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)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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}
|
||||
|
||||
|
@ -102,7 +102,7 @@ html {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Main app */
|
||||
|
@ -174,6 +174,8 @@ class TestBlockFactory {
|
||||
teamID: '',
|
||||
collapsed: false,
|
||||
type: 'custom',
|
||||
sortOrder: 0,
|
||||
isNew: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 = () => {
|
||||
|
@ -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']
|
||||
}
|
||||
|
15
webapp/src/widgets/icons/HandRight.tsx
Normal file
15
webapp/src/widgets/icons/HandRight.tsx
Normal 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'
|
||||
/>
|
||||
)
|
||||
}
|
@ -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 = []
|
||||
|
Loading…
Reference in New Issue
Block a user