Boards as persisted category (#3877)

* WIP

* WIP

* Removed unused webapp util

* Added server tests

* Lint fix

* Updating existing tests

* Updating existing tests

* Updating existing tests

* Fixing existing tests

* Fixing existing tests

* Fixing existing tests

* Added category type and tests

* updated tests

* Fixed integration test

* type fix

* removed seconds from boards name

* wip

* debugging cy test

* Fixed a bug preventing users from collapsing boards category

* Debugging cypress test

* CI

* debugging cy test

* Testing a fix

* reverting test fix

* Handled personal server

* Fixed a case for personal server

* fixed a test
This commit is contained in:
Harshil Sharma 2022-10-26 16:38:03 +05:30 committed by GitHub
parent ba792191cd
commit 8d17dd820e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 746 additions and 304 deletions

View file

@ -178,6 +178,7 @@ func (a *API) userIsGuest(userID string) (bool, error) {
// Response helpers
func (a *API) errorResponse(w http.ResponseWriter, r *http.Request, err error) {
a.logger.Error(err.Error())
errorResponse := model.ErrorResponse{Error: err.Error()}
switch {

View file

@ -489,6 +489,10 @@ func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) {
return
}
if toTeam == "" {
toTeam = board.TeamID
}
if toTeam == "" && !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
a.errorResponse(w, r, model.NewErrPermission("access denied to team"))
return

View file

@ -21,6 +21,8 @@ var (
const linkBoardMessage = "@%s linked the board [%s](%s) with this channel"
const unlinkBoardMessage = "@%s unlinked the board [%s](%s) with this channel"
var errNoDefaultCategoryFound = errors.New("no default category found for user")
func (a *App) GetBoard(boardID string) (*model.Board, error) {
board, err := a.store.GetBoard(boardID)
if err != nil {
@ -142,7 +144,7 @@ func (a *App) getBoardDescendantModifiedInfo(boardID string, latest bool) (int64
return timestamp, modifiedBy, nil
}
func (a *App) setBoardCategoryFromSource(sourceBoardID, destinationBoardID, userID, teamID string) error {
func (a *App) setBoardCategoryFromSource(sourceBoardID, destinationBoardID, userID, teamID string, asTemplate bool) error {
// find source board's category ID for the user
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
if err != nil {
@ -161,10 +163,14 @@ func (a *App) setBoardCategoryFromSource(sourceBoardID, destinationBoardID, user
}
}
// if source board is not mapped to a category for this user,
// then we have nothing more to do.
if destinationCategoryID == "" {
return nil
// if source board is not mapped to a category for this user,
// then move new board to default category
if !asTemplate {
return a.addBoardsToDefaultCategory(userID, teamID, []*model.Board{{ID: destinationBoardID}})
} else {
return nil
}
}
// now that we have source board's category,
@ -184,7 +190,7 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*
}
for _, board := range bab.Boards {
if categoryErr := a.setBoardCategoryFromSource(boardID, board.ID, userID, board.TeamID); categoryErr != nil {
if categoryErr := a.setBoardCategoryFromSource(boardID, board.ID, userID, toTeam, asTemplate); categoryErr != nil {
return nil, nil, categoryErr
}
}
@ -294,9 +300,42 @@ func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*m
return nil
})
if board.TeamID != "0" {
if err := a.addBoardsToDefaultCategory(userID, newBoard.TeamID, []*model.Board{newBoard}); err != nil {
return nil, err
}
}
return newBoard, nil
}
func (a *App) addBoardsToDefaultCategory(userID, teamID string, boards []*model.Board) error {
userCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
if err != nil {
return err
}
defaultCategoryID := ""
for _, categoryBoard := range userCategoryBoards {
if categoryBoard.Name == defaultCategoryBoards {
defaultCategoryID = categoryBoard.ID
break
}
}
if defaultCategoryID == "" {
return fmt.Errorf("%w userID: %s", errNoDefaultCategoryFound, userID)
}
for _, board := range boards {
if err := a.AddUpdateUserCategoryBoard(teamID, userID, defaultCategoryID, board.ID); err != nil {
return err
}
}
return nil
}
func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*model.Board, error) {
var oldChannelID string
var isTemplate bool

View file

@ -55,6 +55,14 @@ func (a *App) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks, userID string, a
}()
}
for _, board := range newBab.Boards {
if !board.IsTemplate {
if err := a.addBoardsToDefaultCategory(userID, board.TeamID, []*model.Board{board}); err != nil {
return nil, err
}
}
}
return newBab, nil
}

View file

@ -3,6 +3,10 @@ package app
import (
"testing"
"github.com/mattermost/focalboard/server/utils"
"github.com/stretchr/testify/assert"
"github.com/mattermost/focalboard/server/model"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
@ -378,3 +382,46 @@ func TestGetBoardCount(t *testing.T) {
require.Equal(t, boardCount, count)
})
}
func TestBoardCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("test addBoardsToDefaultCategory", func(t *testing.T) {
t.Run("no boards default category exists", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
BoardIDs: []string{"board_id_1", "board_id_2"},
},
{
Category: model.Category{ID: "category_id_2", Name: "Category 2"},
BoardIDs: []string{"board_id_3"},
},
{
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
BoardIDs: []string{},
},
}, nil)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "default_category_id",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_1").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_2").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_3").Return(nil)
boards := []*model.Board{
{ID: "board_id_1"},
{ID: "board_id_2"},
{ID: "board_id_3"},
}
err := th.App.addBoardsToDefaultCategory("user_id", "team_id", boards)
assert.NoError(t, err)
})
})
}

View file

@ -1,10 +1,15 @@
package app
import (
"errors"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/utils"
)
var ErrCannotDeleteSystemCategory = errors.New("cannot delete a system category")
var ErrCannotUpdateSystemCategory = errors.New("cannot update a system category")
func (a *App) CreateCategory(category *model.Category) (*model.Category, error) {
category.Hydrate()
if err := category.IsValid(); err != nil {
@ -28,6 +33,10 @@ func (a *App) CreateCategory(category *model.Category) (*model.Category, error)
}
func (a *App) UpdateCategory(category *model.Category) (*model.Category, error) {
if err := category.IsValid(); err != nil {
return nil, err
}
// verify if category belongs to the user
existingCategory, err := a.store.GetCategory(category.ID)
if err != nil {
@ -42,6 +51,17 @@ func (a *App) UpdateCategory(category *model.Category) (*model.Category, error)
return nil, model.ErrCategoryPermissionDenied
}
if existingCategory.TeamID != category.TeamID {
return nil, model.ErrCategoryPermissionDenied
}
if existingCategory.Type == model.CategoryTypeSystem {
// You cannot rename or delete a system category,
// So restoring its name and undeleting it if set so.
category.Name = existingCategory.Name
category.DeleteAt = 0
}
category.UpdateAt = utils.GetMillis()
if err = category.IsValid(); err != nil {
return nil, err
@ -84,6 +104,10 @@ func (a *App) DeleteCategory(categoryID, userID, teamID string) (*model.Category
return nil, model.NewErrInvalidCategory("category doesn't belong to the team")
}
if existingCategory.Type == model.CategoryTypeSystem {
return nil, ErrCannotDeleteSystemCategory
}
if err = a.store.DeleteCategory(categoryID, userID, teamID); err != nil {
return nil, err
}

View file

@ -1,9 +1,105 @@
package app
import "github.com/mattermost/focalboard/server/model"
import (
"fmt"
"github.com/mattermost/focalboard/server/model"
)
const defaultCategoryBoards = "Boards"
func (a *App) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) {
return a.store.GetUserCategoryBoards(userID, teamID)
categoryBoards, err := a.store.GetUserCategoryBoards(userID, teamID)
if err != nil {
return nil, err
}
createdCategoryBoards, err := a.createDefaultCategoriesIfRequired(categoryBoards, userID, teamID)
if err != nil {
return nil, err
}
categoryBoards = append(categoryBoards, createdCategoryBoards...)
return categoryBoards, nil
}
func (a *App) createDefaultCategoriesIfRequired(existingCategoryBoards []model.CategoryBoards, userID, teamID string) ([]model.CategoryBoards, error) {
createdCategories := []model.CategoryBoards{}
boardsCategoryExist := false
for _, categoryBoard := range existingCategoryBoards {
if categoryBoard.Name == defaultCategoryBoards {
boardsCategoryExist = true
}
}
if !boardsCategoryExist {
createdCategoryBoards, err := a.createBoardsCategory(userID, teamID, existingCategoryBoards)
if err != nil {
return nil, err
}
createdCategories = append(createdCategories, *createdCategoryBoards)
}
return createdCategories, nil
}
func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards []model.CategoryBoards) (*model.CategoryBoards, error) {
// create the category
category := model.Category{
Name: defaultCategoryBoards,
UserID: userID,
TeamID: teamID,
Collapsed: false,
Type: model.CategoryTypeSystem,
}
createdCategory, err := a.CreateCategory(&category)
if err != nil {
return nil, fmt.Errorf("createBoardsCategory default category creation failed: %w", err)
}
// once the category is created, we need to move all boards which do not
// belong to any category, into this category.
userBoards, err := a.GetBoardsForUserAndTeam(userID, teamID, false)
if err != nil {
return nil, fmt.Errorf("createBoardsCategory error fetching user's team's boards: %w", err)
}
createdCategoryBoards := &model.CategoryBoards{
Category: *createdCategory,
BoardIDs: []string{},
}
for _, board := range userBoards {
belongsToCategory := false
for _, categoryBoard := range existingCategoryBoards {
for _, boardID := range categoryBoard.BoardIDs {
if boardID == board.ID {
belongsToCategory = true
break
}
}
// stop looking into other categories if
// the board was found in a category
if belongsToCategory {
break
}
}
if !belongsToCategory {
if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, board.ID); err != nil {
return nil, fmt.Errorf("createBoardsCategory failed to add category-less board to the default category, defaultCategoryID: %s, error: %w", createdCategory.ID, err)
}
createdCategoryBoards.BoardIDs = append(createdCategoryBoards.BoardIDs, board.ID)
}
}
return createdCategoryBoards, nil
}
func (a *App) AddUpdateUserCategoryBoard(teamID, userID, categoryID, boardID string) error {

View file

@ -0,0 +1,82 @@
package app
import (
"testing"
"github.com/mattermost/focalboard/server/utils"
"github.com/mattermost/focalboard/server/model"
"github.com/stretchr/testify/assert"
)
func TestGetUserCategoryBoards(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("user had no default category and had boards", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
Name: "Boards",
}, nil)
board1 := &model.Board{
ID: "board_id_1",
}
board2 := &model.Board{
ID: "board_id_2",
}
board3 := &model.Board{
ID: "board_id_3",
}
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
th.Store.EXPECT().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)
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "Boards", categoryBoards[0].Name)
assert.Equal(t, 3, len(categoryBoards[0].BoardIDs))
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_1")
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_2")
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_3")
})
t.Run("user had no default category BUT had no boards", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{}, nil)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "Boards", categoryBoards[0].Name)
assert.Equal(t, 0, len(categoryBoards[0].BoardIDs))
})
t.Run("user already had a default Boards category with boards in it", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{Name: "Boards"},
BoardIDs: []string{"board_id_1", "board_id_2"},
},
}, nil)
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, "Boards", categoryBoards[0].Name)
assert.Equal(t, 2, len(categoryBoards[0].BoardIDs))
})
}

295
server/app/category_test.go Normal file
View file

@ -0,0 +1,295 @@
package app
import (
"testing"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/utils"
"github.com/stretchr/testify/assert"
)
func TestCreateCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
}, nil)
category := &model.Category{
Name: "Category",
UserID: "user_id",
TeamID: "team_id",
Type: "custom",
}
createdCategory, err := th.App.CreateCategory(category)
assert.NotNil(t, createdCategory)
assert.NoError(t, err)
})
t.Run("creating invalid category", func(t *testing.T) {
category := &model.Category{
Name: "", // empty name shouldn't be allowed
UserID: "user_id",
TeamID: "team_id",
Type: "custom",
}
createdCategory, err := th.App.CreateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.Name = "Name"
category.UserID = "" // empty creator user id shouldn't be allowed
createdCategory, err = th.App.CreateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.UserID = "user_id"
category.TeamID = "" // empty TeamID shouldn't be allowed
createdCategory, err = th.App.CreateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.Type = "invalid" // unknown type shouldn't be allowed
createdCategory, err = th.App.CreateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
})
}
func TestUpdateCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", 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)
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
ID: "category_id_1",
Name: "Category",
}, nil)
category := &model.Category{
ID: "category_id_1",
Name: "Category",
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "custom",
}
updatedCategory, err := th.App.UpdateCategory(category)
assert.NotNil(t, updatedCategory)
assert.NoError(t, err)
})
t.Run("updating invalid category", func(t *testing.T) {
category := &model.Category{
ID: "category_id_1",
Name: "Name",
UserID: "user_id",
TeamID: "team_id",
Type: "custom",
}
category.ID = ""
createdCategory, err := th.App.UpdateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.ID = "category_id_1"
category.Name = ""
createdCategory, err = th.App.UpdateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.Name = "Name"
category.UserID = "" // empty creator user id shouldn't be allowed
createdCategory, err = th.App.UpdateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.UserID = "user_id"
category.TeamID = "" // empty TeamID shouldn't be allowed
createdCategory, err = th.App.UpdateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
category.Type = "invalid" // unknown type shouldn't be allowed
createdCategory, err = th.App.UpdateCategory(category)
assert.Nil(t, createdCategory)
assert.Error(t, err)
})
t.Run("trying to update someone else's 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: "Category",
UserID: "user_id_2",
TeamID: "team_id_1",
Type: "custom",
}
updatedCategory, err := th.App.UpdateCategory(category)
assert.Nil(t, updatedCategory)
assert.Error(t, err)
})
t.Run("trying to update some other team's 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: "Category",
UserID: "user_id_1",
TeamID: "team_id_2",
Type: "custom",
}
updatedCategory, err := th.App.UpdateCategory(category)
assert.Nil(t, updatedCategory)
assert.Error(t, err)
})
t.Run("should not be allowed to rename system 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: "system",
}, nil).Times(1)
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
Name: "Category",
TeamID: "team_id_1",
UserID: "user_id_1",
Type: "system",
Collapsed: true,
}, nil).Times(1)
category := &model.Category{
ID: "category_id_1",
Name: "Updated Name",
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "system",
}
updatedCategory, err := th.App.UpdateCategory(category)
assert.NotNil(t, updatedCategory)
assert.NoError(t, err)
assert.Equal(t, "Category", updatedCategory.Name)
})
t.Run("should be allowed to collapse and expand any category type", 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: "system",
Collapsed: false,
}, nil).Times(1)
th.Store.EXPECT().UpdateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
Name: "Category",
TeamID: "team_id_1",
UserID: "user_id_1",
Type: "system",
Collapsed: true,
}, nil).Times(1)
category := &model.Category{
ID: "category_id_1",
Name: "Updated Name",
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "system",
Collapsed: true,
}
updatedCategory, err := th.App.UpdateCategory(category)
assert.NotNil(t, updatedCategory)
assert.NoError(t, err)
assert.Equal(t, "Category", updatedCategory.Name, "The name should have not been updated")
assert.True(t, updatedCategory.Collapsed)
})
}
func TestDeleteCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
ID: "category_id_1",
DeleteAt: 0,
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "custom",
}, nil)
th.Store.EXPECT().DeleteCategory("category_id_1", "user_id_1", "team_id_1").Return(nil)
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
DeleteAt: 10000,
}, nil)
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
assert.NotNil(t, deletedCategory)
assert.NoError(t, err)
})
t.Run("trying to delete already deleted category", func(t *testing.T) {
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
ID: "category_id_1",
DeleteAt: 1000,
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "custom",
}, nil)
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
assert.NotNil(t, deletedCategory)
assert.NoError(t, err)
})
t.Run("trying to delete system category", func(t *testing.T) {
th.Store.EXPECT().GetCategory("category_id_1").Return(&model.Category{
ID: "category_id_1",
DeleteAt: 0,
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "system",
}, nil)
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
assert.Nil(t, deletedCategory)
assert.Error(t, err)
})
}

View file

@ -4,6 +4,8 @@ import (
"bytes"
"testing"
"github.com/mattermost/focalboard/server/utils"
"github.com/golang/mock/gomock"
"github.com/mattermost/focalboard/server/model"
"github.com/stretchr/testify/require"
@ -47,6 +49,14 @@ func TestApp_ImportArchive(t *testing.T) {
th.Store.EXPECT().GetMembersForBoard(board.ID).AnyTimes().Return([]*model.BoardMember{boardMember}, nil)
th.Store.EXPECT().GetBoard(board.ID).Return(board, nil)
th.Store.EXPECT().GetMemberForBoard(board.ID, "user").Return(boardMember, nil)
th.Store.EXPECT().GetUserCategoryBoards("user", "test-team")
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category_id",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user", "test-team", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user", "boards_category_id", utils.Anything).Return(nil)
err := th.App.ImportArchive(r, opts)
require.NoError(t, err, "import archive should not fail")

View file

@ -3,6 +3,8 @@ package app
import (
"testing"
"github.com/mattermost/focalboard/server/utils"
"github.com/mattermost/focalboard/server/model"
"github.com/stretchr/testify/assert"
)
@ -26,10 +28,19 @@ func TestPrepareOnboardingTour(t *testing.T) {
}
th.Store.EXPECT().GetTemplateBoards("0", "").Return([]*model.Board{&welcomeBoard}, nil)
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).Return(&model.BoardsAndBlocks{Boards: []*model.Board{&welcomeBoard}},
th.Store.EXPECT().DuplicateBoard(welcomeBoard.ID, userID, teamID, false).Return(&model.BoardsAndBlocks{Boards: []*model.Board{
{
ID: "board_id_2",
Title: "Welcome to Boards!",
TeamID: "0",
IsTemplate: true,
},
}},
nil, nil)
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(3)
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).AnyTimes()
th.Store.EXPECT().GetMembersForBoard(welcomeBoard.ID).Return([]*model.BoardMember{}, nil).Times(2)
th.Store.EXPECT().GetMembersForBoard("board_id_2").Return([]*model.BoardMember{}, nil).Times(1)
th.Store.EXPECT().GetBoard(welcomeBoard.ID).Return(&welcomeBoard, nil).Times(1)
th.Store.EXPECT().GetBoard("board_id_2").Return(&welcomeBoard, nil).Times(1)
th.Store.EXPECT().GetUsersByTeam("0", "").Return([]*model.User{}, nil)
privateWelcomeBoard := model.Board{
@ -40,7 +51,7 @@ func TestPrepareOnboardingTour(t *testing.T) {
Type: model.BoardTypePrivate,
}
newType := model.BoardTypePrivate
th.Store.EXPECT().PatchBoard("board_id_1", &model.BoardPatch{Type: &newType}, "user_id_1").Return(&privateWelcomeBoard, nil)
th.Store.EXPECT().PatchBoard("board_id_2", &model.BoardPatch{Type: &newType}, "user_id_1").Return(&privateWelcomeBoard, nil)
userPreferencesPatch := model.UserPreferencesPatch{
UpdatedFields: map[string]string{
@ -51,7 +62,22 @@ func TestPrepareOnboardingTour(t *testing.T) {
}
th.Store.EXPECT().PatchUserPreferences(userID, userPreferencesPatch).Return(nil, nil)
th.Store.EXPECT().GetUserCategoryBoards(userID, "0").Return([]model.CategoryBoards{}, nil)
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{}, nil).Times(1)
// when this is called the second time, the default category is created so we need to include that in the response list
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
},
}, nil).Times(1)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil).Times(1)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "boards_category",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id_1", teamID, false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", "board_id_2").Return(nil)
teamID, boardID, err := th.App.PrepareOnboardingTour(userID, teamID)
assert.NoError(t, err)
@ -89,7 +115,12 @@ func TestCreateWelcomeBoard(t *testing.T) {
}
newType := model.BoardTypePrivate
th.Store.EXPECT().PatchBoard("board_id_1", &model.BoardPatch{Type: &newType}, "user_id_1").Return(&privateWelcomeBoard, nil)
th.Store.EXPECT().GetUserCategoryBoards(userID, "0")
th.Store.EXPECT().GetUserCategoryBoards(userID, "team_id").Return([]model.CategoryBoards{
{
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)
boardID, err := th.App.createWelcomeBoard(userID, teamID)
assert.Nil(t, err)

View file

@ -729,7 +729,8 @@ func TestDeleteBoardsAndBlocks(t *testing.T) {
// the user is an admin of the first board
newBoard1 := &model.Board{
Type: model.BoardTypeOpen,
Type: model.BoardTypeOpen,
TeamID: "team_id_1",
}
board1, err := th.Server.App().CreateBoard(newBoard1, th.GetUser1().ID, true)
require.NoError(t, err)
@ -737,7 +738,8 @@ func TestDeleteBoardsAndBlocks(t *testing.T) {
// but not of the second
newBoard2 := &model.Board{
Type: model.BoardTypeOpen,
Type: model.BoardTypeOpen,
TeamID: "team_id_1",
}
board2, err := th.Server.App().CreateBoard(newBoard2, th.GetUser1().ID, false)
require.NoError(t, err)

View file

@ -43,6 +43,18 @@ type TestCase struct {
totalResults int
}
func (tt TestCase) identifier() string {
return fmt.Sprintf(
"url: %s method: %s body: %s userRoles: %s expectedStatusCode: %d totalResults: %d",
tt.url,
tt.method,
tt.body,
tt.userRole,
tt.expectedStatusCode,
tt.totalResults,
)
}
func setupClients(th *TestHelper) Clients {
// user1
clients := Clients{
@ -262,7 +274,6 @@ func runTestCases(t *testing.T, ttCases []TestCase, testData TestData, clients C
url = strings.ReplaceAll(url, "{PUBLIC_BOARD_ID}", testData.publicBoard.ID)
url = strings.ReplaceAll(url, "{PUBLIC_TEMPLATE_ID}", testData.publicTemplate.ID)
url = strings.ReplaceAll(url, "{PRIVATE_TEMPLATE_ID}", testData.privateTemplate.ID)
url = strings.ReplaceAll(url, "{USER_ANON_ID}", userAnonID)
url = strings.ReplaceAll(url, "{USER_NO_TEAM_MEMBER_ID}", userNoTeamMemberID)
url = strings.ReplaceAll(url, "{USER_TEAM_MEMBER_ID}", userTeamMemberID)
@ -273,7 +284,7 @@ func runTestCases(t *testing.T, ttCases []TestCase, testData TestData, clients C
url = strings.ReplaceAll(url, "{USER_GUEST_ID}", userGuestID)
if strings.Contains(url, "{") || strings.Contains(url, "}") {
require.Fail(t, "Unreplaced tokens in url", url)
require.Fail(t, "Unreplaced tokens in url", url, tc.identifier())
}
var response *http.Response
@ -296,28 +307,28 @@ func runTestCases(t *testing.T, ttCases []TestCase, testData TestData, clients C
defer response.Body.Close()
}
require.Equal(t, tc.expectedStatusCode, response.StatusCode)
require.Equal(t, tc.expectedStatusCode, response.StatusCode, tc.identifier())
if tc.expectedStatusCode >= 200 && tc.expectedStatusCode < 300 {
require.NoError(t, err)
require.NoError(t, err, tc.identifier())
}
if tc.expectedStatusCode >= 200 && tc.expectedStatusCode < 300 {
body, err := io.ReadAll(response.Body)
if err != nil {
require.Fail(t, err.Error())
require.Fail(t, err.Error(), tc.identifier())
}
if strings.HasPrefix(string(body), "[") {
var data []interface{}
err = json.Unmarshal(body, &data)
if err != nil {
require.Fail(t, err.Error())
require.Fail(t, err.Error(), tc.identifier())
}
require.Len(t, data, tc.totalResults)
require.Len(t, data, tc.totalResults, tc.identifier())
} else {
if tc.totalResults > 0 {
require.Equal(t, 1, tc.totalResults)
require.Greater(t, len(string(body)), 2)
require.Greater(t, len(string(body)), 2, tc.identifier())
} else {
require.Len(t, string(body), 2)
require.Len(t, string(body), 2, tc.identifier())
}
}
}
@ -2865,14 +2876,14 @@ func TestPermissionsClientConfig(t *testing.T) {
func TestPermissionsGetCategories(t *testing.T) {
ttCases := []TestCase{
{"/teams/test-team/categories", methodGet, "", userAnon, http.StatusUnauthorized, 0},
{"/teams/test-team/categories", methodGet, "", userNoTeamMember, http.StatusOK, 0},
{"/teams/test-team/categories", methodGet, "", userTeamMember, http.StatusOK, 0},
{"/teams/test-team/categories", methodGet, "", userViewer, http.StatusOK, 0},
{"/teams/test-team/categories", methodGet, "", userCommenter, http.StatusOK, 0},
{"/teams/test-team/categories", methodGet, "", userEditor, http.StatusOK, 0},
{"/teams/test-team/categories", methodGet, "", userAdmin, http.StatusOK, 0},
{"/teams/test-team/categories", methodGet, "", userGuest, http.StatusOK, 0},
{"/teams/test-team/categories", methodGet, "", userAnon, http.StatusUnauthorized, 1},
{"/teams/test-team/categories", methodGet, "", userNoTeamMember, http.StatusOK, 1},
{"/teams/test-team/categories", methodGet, "", userTeamMember, http.StatusOK, 1},
{"/teams/test-team/categories", methodGet, "", userViewer, http.StatusOK, 1},
{"/teams/test-team/categories", methodGet, "", userCommenter, http.StatusOK, 1},
{"/teams/test-team/categories", methodGet, "", userEditor, http.StatusOK, 1},
{"/teams/test-team/categories", methodGet, "", userAdmin, http.StatusOK, 1},
{"/teams/test-team/categories", methodGet, "", userGuest, http.StatusOK, 1},
}
t.Run("plugin", func(t *testing.T) {
@ -2960,6 +2971,7 @@ func TestPermissionsUpdateCategory(t *testing.T) {
UserID: userID,
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
Type: "custom",
})
}

View file

@ -2,12 +2,18 @@ package model
import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/mattermost/focalboard/server/utils"
)
const (
CategoryTypeSystem = "system"
CategoryTypeCustom = "custom"
)
// Category is a board category
// swagger:model
type Category struct {
@ -42,12 +48,19 @@ type Category struct {
// Category's state in client side
// required: true
Collapsed bool `json:"collapsed"`
// 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 == "" {
c.Type = CategoryTypeCustom
}
}
func (c *Category) IsValid() error {
@ -67,6 +80,10 @@ func (c *Category) IsValid() error {
return NewErrInvalidCategory("category team id ID cannot be empty")
}
if c.Type != CategoryTypeCustom && c.Type != CategoryTypeSystem {
return NewErrInvalidCategory(fmt.Sprintf("category type is invalid. Allowed types: %s and %s", CategoryTypeSystem, CategoryTypeCustom))
}
return nil
}

View file

@ -12,7 +12,7 @@ import (
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").
Select("id", "name", "user_id", "team_id", "create_at", "update_at", "delete_at", "collapsed", "type").
From(s.tablePrefix + "categories").
Where(sq.Eq{"id": id})
@ -47,6 +47,7 @@ func (s *SQLStore) createCategory(db sq.BaseRunner, category model.Category) err
"update_at",
"delete_at",
"collapsed",
"type",
).
Values(
category.ID,
@ -57,6 +58,7 @@ func (s *SQLStore) createCategory(db sq.BaseRunner, category model.Category) err
category.UpdateAt,
category.DeleteAt,
category.Collapsed,
category.Type,
)
_, err := query.Exec()
@ -73,7 +75,10 @@ func (s *SQLStore) updateCategory(db sq.BaseRunner, category model.Category) err
Set("name", category.Name).
Set("update_at", category.UpdateAt).
Set("collapsed", category.Collapsed).
Where(sq.Eq{"id": category.ID})
Where(sq.Eq{
"id": category.ID,
"delete_at": 0,
})
_, err := query.Exec()
if err != nil {
@ -88,9 +93,10 @@ func (s *SQLStore) deleteCategory(db sq.BaseRunner, categoryID, userID, teamID s
Update(s.tablePrefix+"categories").
Set("delete_at", utils.GetMillis()).
Where(sq.Eq{
"id": categoryID,
"user_id": userID,
"team_id": teamID,
"id": categoryID,
"user_id": userID,
"team_id": teamID,
"delete_at": 0,
})
_, err := query.Exec()
@ -109,7 +115,7 @@ 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").
Select("id", "name", "user_id", "team_id", "create_at", "update_at", "delete_at", "collapsed", "type").
From(s.tablePrefix + "categories").
Where(sq.Eq{
"user_id": userID,
@ -140,6 +146,7 @@ func (s *SQLStore) categoriesFromRows(rows *sql.Rows) ([]model.Category, error)
&category.UpdateAt,
&category.DeleteAt,
&category.Collapsed,
&category.Type,
)
if err != nil {

View file

@ -0,0 +1 @@
ALTER TABLE {{.prefix}}categories DROP COLUMN type;

View file

@ -0,0 +1,2 @@
ALTER TABLE {{.prefix}}categories ADD COLUMN type varchar(64);
UPDATE {{.prefix}}categories SET type = 'custom' WHERE type IS NULL;

View file

@ -0,0 +1,5 @@
package utils
import "github.com/stretchr/testify/mock"
var Anything = mock.MatchedBy(func(interface{}) bool { return true })

View file

@ -133,6 +133,9 @@ describe('Create and delete board / card', () => {
// Delete board
cy.log('**Delete board**')
cy.get('.Sidebar .octo-sidebar-list').then((el) => {
cy.log(el.text())
})
cy.get('.Sidebar .octo-sidebar-list').
contains(boardTitle).
parent().

View file

@ -207,48 +207,6 @@ exports[`src/components/workspace return workspace and showcard 1`] = `
</div>
</div>
</div>
<div
class="SidebarCategory"
>
<div
class="octo-sidebar-item category ' expanded "
>
<div
class="octo-sidebar-title category-title"
title="Boards"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Boards
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
<div
class="octo-sidebar-item subitem no-views"
>
No boards inside
</div>
</div>
</div>
<div
class="octo-spacer"
@ -1348,48 +1306,6 @@ exports[`src/components/workspace should match snapshot 1`] = `
</div>
</div>
</div>
<div
class="SidebarCategory"
>
<div
class="octo-sidebar-item category ' expanded "
>
<div
class="octo-sidebar-title category-title"
title="Boards"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
Boards
<div
class="sidebarCategoriesTour"
/>
</div>
<div
class=""
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
</div>
<div
class="octo-sidebar-item subitem no-views"
>
No boards inside
</div>
</div>
</div>
<div
class="octo-spacer"

View file

@ -152,49 +152,6 @@ exports[`components/sidebarSidebar dont show hidden boards 1`] = `
No boards inside
</div>
</div>
<div
class="SidebarCategory"
>
<div
class="octo-sidebar-item category ' expanded "
>
<div
class="octo-sidebar-title category-title"
title="Boards"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
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
class="octo-sidebar-item subitem no-views"
>
No boards inside
</div>
</div>
</div>
<div
class="octo-spacer"
@ -459,49 +416,6 @@ exports[`components/sidebarSidebar sidebar hidden 1`] = `
</div>
</div>
</div>
<div
class="SidebarCategory"
>
<div
class="octo-sidebar-item category ' expanded "
>
<div
class="octo-sidebar-title category-title"
title="Boards"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
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
class="octo-sidebar-item subitem no-views"
>
No boards inside
</div>
</div>
</div>
<div
class="octo-spacer"
@ -804,49 +718,6 @@ exports[`components/sidebarSidebar some categories hidden 1`] = `
</div>
</div>
</div>
<div
class="SidebarCategory"
>
<div
class="octo-sidebar-item category ' expanded "
>
<div
class="octo-sidebar-title category-title"
title="Boards"
>
<i
class="CompassIcon icon-chevron-down ChevronDownIcon"
/>
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
class="octo-sidebar-item subitem no-views"
>
No boards inside
</div>
</div>
</div>
<div
class="octo-spacer"

View file

@ -213,7 +213,7 @@ describe('components/sidebarSidebar', () => {
expect(sidebarBoards.length).toBe(0)
const noBoardsText = getAllByText('No boards inside')
expect(noBoardsText.length).toBe(2) // one for custom category, one for default category
expect(noBoardsText.length).toBe(1)
})
test('some categories hidden', () => {

View file

@ -38,7 +38,6 @@ import {getCurrentViewId} from '../../store/views'
import SidebarCategory from './sidebarCategory'
import SidebarSettingsMenu from './sidebarSettingsMenu'
import SidebarUserMenu from './sidebarUserMenu'
import {addMissingItems} from './utils'
type Props = {
activeBoardId?: string
@ -60,9 +59,8 @@ const Sidebar = (props: Props) => {
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions())
const boards = useAppSelector(getMySortedBoards)
const dispatch = useAppDispatch()
const partialCategories = useAppSelector<CategoryBoards[]>(getSidebarCategories)
const sidebarCategories = useAppSelector<CategoryBoards[]>(getSidebarCategories)
const me = useAppSelector<IUser|null>(getMe)
const sidebarCategories = addMissingItems(partialCategories, boards)
const activeViewID = useAppSelector(getCurrentViewId)
useEffect(() => {
@ -101,6 +99,8 @@ const Sidebar = (props: Props) => {
}, [windowDimensions])
if (!boards) {
// eslint-disable-next-line no-console
console.log('AAAA')
return <div/>
}
@ -115,10 +115,14 @@ const Sidebar = (props: Props) => {
}
if (!me) {
// eslint-disable-next-line no-console
console.log('BBBB')
return <div/>
}
if (isHidden) {
// eslint-disable-next-line no-console
console.log('CCCC')
return (
<div className='Sidebar octo-sidebar hidden'>
<div className='octo-sidebar-header show-button'>
@ -145,6 +149,8 @@ const Sidebar = (props: Props) => {
)
}
// eslint-disable-next-line no-console
console.log('DDDD')
return (
<div className='Sidebar octo-sidebar'>
{!Utils.isFocalboardPlugin() &&

View file

@ -246,14 +246,8 @@ const SidebarCategory = (props: Props) => {
position='auto'
parentRef={menuWrapperRef}
>
<Menu.Text
id='createNewCategory'
name={intl.formatMessage({id: 'SidebarCategories.CategoryMenu.CreateNew', defaultMessage: 'Create New Category'})}
icon={<CreateNewFolder/>}
onClick={handleCreateNewCategory}
/>
{
props.categoryBoards.id !== '' &&
props.categoryBoards.type === 'custom' &&
<React.Fragment>
<Menu.Text
id='updateCategory'
@ -269,14 +263,14 @@ const SidebarCategory = (props: Props) => {
onClick={() => setShowDeleteCategoryDialog(true)}
/>
<Menu.Separator/>
<Menu.Text
id='createNewCategory'
name={intl.formatMessage({id: 'SidebarCategories.CategoryMenu.CreateNew', defaultMessage: 'Create New Category'})}
icon={<CreateNewFolder/>}
onClick={handleCreateNewCategory}
/>
</React.Fragment>
}
<Menu.Text
id='createNewCategory'
name={intl.formatMessage({id: 'SidebarCategories.CategoryMenu.CreateNew', defaultMessage: 'Create New Category'})}
icon={<CreateNewFolder/>}
onClick={handleCreateNewCategory}
/>
</Menu>
</MenuWrapper>
</div>
@ -305,7 +299,7 @@ const SidebarCategory = (props: Props) => {
/>
)
})}
{!collapsed && props.boards.map((board: Board) => {
{!collapsed && props.boards.filter((board) => !board.isTemplate).map((board: Board) => {
if (!isBoardVisible(board.id)) {
return null
}

View file

@ -1,35 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {CategoryBoards, DefaultCategory} from '../../store/sidebar'
import {Block} from '../../blocks/block'
import {Board} from '../../blocks/board'
export function addMissingItems(sidebarCategories: CategoryBoards[], allItems: Array<Block | Board>): CategoryBoards[] {
const blocksInCategories = new Map<string, boolean>()
sidebarCategories.forEach(
(category) => category.boardIDs.forEach(
(boardID) => blocksInCategories.set(boardID, true),
),
)
const defaultCategory: CategoryBoards = {
...DefaultCategory,
boardIDs: [],
}
allItems.forEach((block) => {
if (!blocksInCategories.get(block.id)) {
defaultCategory.boardIDs.push(block.id)
}
})
// sidebarCategories comes from store,
// so is frozen and can't be extended.
// So creating new array from it.
return [
...sidebarCategories,
defaultCategory,
]
}

View file

@ -7,6 +7,8 @@ import {default as client} from '../octoClient'
import {RootState} from './index'
export type CategoryType = 'system' | 'custom'
interface Category {
id: string
name: string
@ -16,6 +18,7 @@ interface Category {
updateAt: number
deleteAt: number
collapsed: boolean
type: CategoryType
}
interface CategoryBoards extends Category {

View file

@ -173,6 +173,7 @@ class TestBlockFactory {
userID: '',
teamID: '',
collapsed: false,
type: 'custom',
}
}