mirror of
https://github.com/mattermost/focalboard.git
synced 2024-12-24 13:43:12 +02:00
Merge branch 'main' into read-only-view
This commit is contained in:
commit
12f6dedc5b
4
Makefile
4
Makefile
@ -146,7 +146,7 @@ server-test-mini-sqlite: setup-go-work ## Run server tests using sqlite
|
||||
|
||||
server-test-mysql: export FOCALBOARD_UNIT_TESTING=1
|
||||
server-test-mysql: export FOCALBOARD_STORE_TEST_DB_TYPE=mysql
|
||||
server-test-mysql: export FOCALBOARD_STORE_TEST_DOCKER_PORT=44445
|
||||
server-test-mysql: export FOCALBOARD_STORE_TEST_DOCKER_PORT=44446
|
||||
|
||||
server-test-mysql: setup-go-work ## Run server tests using mysql
|
||||
@echo Starting docker container for mysql
|
||||
@ -174,7 +174,7 @@ server-test-mariadb: templates-archive ## Run server tests using mysql
|
||||
|
||||
server-test-postgres: export FOCALBOARD_UNIT_TESTING=1
|
||||
server-test-postgres: export FOCALBOARD_STORE_TEST_DB_TYPE=postgres
|
||||
server-test-postgres: export FOCALBOARD_STORE_TEST_DOCKER_PORT=44446
|
||||
server-test-postgres: export FOCALBOARD_STORE_TEST_DOCKER_PORT=44447
|
||||
|
||||
server-test-postgres: setup-go-work ## Run server tests using postgres
|
||||
@echo Starting docker container for postgres
|
||||
|
@ -42,6 +42,10 @@ See the [plugin setup guide](https://www.focalboard.com/download/mattermost/) fo
|
||||
|
||||
**Ubuntu**: You can download and run the compiled Focalboard **Personal Server** on Ubuntu by following [our latest install guide](https://www.focalboard.com/download/personal-edition/ubuntu/).
|
||||
|
||||
### API Docs
|
||||
|
||||
Boards API docs can be found over at https://htmlpreview.github.io/?https://github.com/mattermost/focalboard/blob/main/server/swagger/docs/html/index.html
|
||||
|
||||
## Contribute to Focalboard
|
||||
|
||||
Contribute code, bug reports, and ideas to the future of the Focalboard project. We welcome your input! Please see [CONTRIBUTING](CONTRIBUTING.md) for details on how to get involved.
|
||||
|
@ -15,7 +15,7 @@ services:
|
||||
retries: 3
|
||||
tmpfs: /var/lib/mysql
|
||||
ports:
|
||||
- 44445:3306
|
||||
- 44446:3306
|
||||
|
||||
start_dependencies:
|
||||
image: mattermost/mattermost-wait-for-dep:latest
|
||||
|
@ -13,7 +13,7 @@ services:
|
||||
retries: 3
|
||||
tmpfs: /var/lib/postgresql/data
|
||||
ports:
|
||||
- 44446:5432
|
||||
- 44447:5432
|
||||
|
||||
start_dependencies:
|
||||
image: mattermost/mattermost-wait-for-dep:latest
|
||||
|
@ -7,7 +7,7 @@ replace github.com/mattermost/focalboard/server => ../server
|
||||
require (
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/mattermost/focalboard/server v0.0.0-00010101000000-000000000000
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221130200243-06e964b86b0d
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221214122404-8d90c7042f93
|
||||
github.com/webview/webview v0.0.0-20220314230258-a2b7746141c3
|
||||
)
|
||||
|
||||
|
@ -873,6 +873,8 @@ github.com/mattermost/mattermost-server/v6 v6.0.0-20220802151854-f07c31c5d933 h1
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220802151854-f07c31c5d933/go.mod h1:otnBnKY9Y0eNkUKeD161de+BUBlESwANTnrkPT/392Y=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221130200243-06e964b86b0d h1:CKJXDUCkRrfy1U9sZHOpvACOtkthV5iWt2boHUK720I=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221130200243-06e964b86b0d/go.mod h1:U3gSM0I15WSMHPpDEU30mmc4JrbSDk+8F1+MFLOHWD0=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221214122404-8d90c7042f93 h1:mGN2D6KhjKosQdZ+BHzmWxsA/tRK9FiR+nUd38nSZQY=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221214122404-8d90c7042f93/go.mod h1:U3gSM0I15WSMHPpDEU30mmc4JrbSDk+8F1+MFLOHWD0=
|
||||
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8 h1:gwliVjCTqAC01mSCNqa5nJ/4MmGq50vrjsottIhQ4d8=
|
||||
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8/go.mod h1:jxM3g1bx+k2Thz7jofcHguBS8TZn5Pc+o5MGmORObhw=
|
||||
github.com/mattermost/morph v1.0.5-0.20221115094356-4c18a75b1f5e/go.mod h1:xo0ljDknTpPxEdhhrUdwhLCexIsYyDKS6b41HqG8wGU=
|
||||
|
@ -7,7 +7,7 @@ require (
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/mattermost/focalboard/server v0.0.0-20220818150333-feb49eaf197a
|
||||
github.com/mattermost/mattermost-plugin-api v0.0.29-0.20220801143717-73008cfda2fb
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221130200243-06e964b86b0d
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221214122404-8d90c7042f93
|
||||
github.com/stretchr/testify v1.8.1
|
||||
)
|
||||
|
||||
|
@ -1036,6 +1036,8 @@ github.com/mattermost/mattermost-server/v6 v6.0.0-20220802151854-f07c31c5d933 h1
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220802151854-f07c31c5d933/go.mod h1:otnBnKY9Y0eNkUKeD161de+BUBlESwANTnrkPT/392Y=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221130200243-06e964b86b0d h1:CKJXDUCkRrfy1U9sZHOpvACOtkthV5iWt2boHUK720I=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221130200243-06e964b86b0d/go.mod h1:U3gSM0I15WSMHPpDEU30mmc4JrbSDk+8F1+MFLOHWD0=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221214122404-8d90c7042f93 h1:mGN2D6KhjKosQdZ+BHzmWxsA/tRK9FiR+nUd38nSZQY=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221214122404-8d90c7042f93/go.mod h1:U3gSM0I15WSMHPpDEU30mmc4JrbSDk+8F1+MFLOHWD0=
|
||||
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8 h1:gwliVjCTqAC01mSCNqa5nJ/4MmGq50vrjsottIhQ4d8=
|
||||
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8/go.mod h1:jxM3g1bx+k2Thz7jofcHguBS8TZn5Pc+o5MGmORObhw=
|
||||
github.com/mattermost/morph v1.0.5-0.20221115094356-4c18a75b1f5e h1:VfNz+fvJ3DxOlALM22Eea8ONp5jHrybKBCcCtDPVlss=
|
||||
|
@ -6,7 +6,7 @@
|
||||
"support_url": "https://github.com/mattermost/focalboard/issues",
|
||||
"release_notes_url": "https://github.com/mattermost/focalboard/releases",
|
||||
"icon_path": "assets/starter-template-icon.svg",
|
||||
"version": "7.7.0",
|
||||
"version": "7.8.0",
|
||||
"min_server_version": "7.2.0",
|
||||
"server": {
|
||||
"executables": {
|
||||
|
@ -10,7 +10,6 @@ import (
|
||||
"github.com/mattermost/focalboard/mattermost-plugin/server/boards"
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/app"
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin"
|
||||
"github.com/mattermost/mattermost-server/v6/product"
|
||||
@ -25,26 +24,28 @@ const (
|
||||
var errServiceTypeAssert = errors.New("type assertion failed")
|
||||
|
||||
func init() {
|
||||
app.RegisterProduct(boardsProductName, app.ProductManifest{
|
||||
product.RegisterProduct(boardsProductName, product.Manifest{
|
||||
Initializer: newBoardsProduct,
|
||||
Dependencies: map[app.ServiceKey]struct{}{
|
||||
app.TeamKey: {},
|
||||
app.ChannelKey: {},
|
||||
app.UserKey: {},
|
||||
app.PostKey: {},
|
||||
app.BotKey: {},
|
||||
app.ClusterKey: {},
|
||||
app.ConfigKey: {},
|
||||
app.LogKey: {},
|
||||
app.LicenseKey: {},
|
||||
app.FilestoreKey: {},
|
||||
app.FileInfoStoreKey: {},
|
||||
app.RouterKey: {},
|
||||
app.CloudKey: {},
|
||||
app.KVStoreKey: {},
|
||||
app.StoreKey: {},
|
||||
app.SystemKey: {},
|
||||
app.PreferencesKey: {},
|
||||
Dependencies: map[product.ServiceKey]struct{}{
|
||||
product.TeamKey: {},
|
||||
product.ChannelKey: {},
|
||||
product.UserKey: {},
|
||||
product.PostKey: {},
|
||||
product.PermissionsKey: {},
|
||||
product.BotKey: {},
|
||||
product.ClusterKey: {},
|
||||
product.ConfigKey: {},
|
||||
product.LogKey: {},
|
||||
product.LicenseKey: {},
|
||||
product.FilestoreKey: {},
|
||||
product.FileInfoStoreKey: {},
|
||||
product.RouterKey: {},
|
||||
product.CloudKey: {},
|
||||
product.KVStoreKey: {},
|
||||
product.StoreKey: {},
|
||||
product.SystemKey: {},
|
||||
product.PreferencesKey: {},
|
||||
product.HooksKey: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -73,128 +74,151 @@ type boardsProduct struct {
|
||||
boardsApp *boards.BoardsApp
|
||||
}
|
||||
|
||||
func newBoardsProduct(_ *app.Server, services map[app.ServiceKey]interface{}) (app.Product, error) {
|
||||
boards := &boardsProduct{}
|
||||
func newBoardsProduct(services map[product.ServiceKey]interface{}) (product.Product, error) {
|
||||
boardsProd := &boardsProduct{}
|
||||
|
||||
if err := populateServices(boardsProd, services); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boardsProd.logger.Info("Creating boards service")
|
||||
|
||||
adapter := newServiceAPIAdapter(boardsProd)
|
||||
boardsApp, err := boards.NewBoardsApp(adapter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Boards service: %w", err)
|
||||
}
|
||||
|
||||
boardsProd.boardsApp = boardsApp
|
||||
|
||||
// Add the Boards services API to the services map so other products can access Boards functionality.
|
||||
boardsAPI := boards.NewBoardsServiceAPI(boardsApp)
|
||||
services[product.BoardsKey] = boardsAPI
|
||||
|
||||
return boardsProd, nil
|
||||
}
|
||||
|
||||
// populateServices populates the boardProduct with all the services needed from the suite.
|
||||
func populateServices(boardsProd *boardsProduct, services map[product.ServiceKey]interface{}) error {
|
||||
for key, service := range services {
|
||||
switch key {
|
||||
case app.TeamKey:
|
||||
case product.TeamKey:
|
||||
teamService, ok := service.(product.TeamService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.teamService = teamService
|
||||
case app.ChannelKey:
|
||||
boardsProd.teamService = teamService
|
||||
case product.ChannelKey:
|
||||
channelService, ok := service.(product.ChannelService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.channelService = channelService
|
||||
case app.UserKey:
|
||||
boardsProd.channelService = channelService
|
||||
case product.UserKey:
|
||||
userService, ok := service.(product.UserService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.userService = userService
|
||||
case app.PostKey:
|
||||
boardsProd.userService = userService
|
||||
case product.PostKey:
|
||||
postService, ok := service.(product.PostService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.postService = postService
|
||||
case app.PermissionsKey:
|
||||
boardsProd.postService = postService
|
||||
case product.PermissionsKey:
|
||||
permissionsService, ok := service.(product.PermissionService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.permissionsService = permissionsService
|
||||
case app.BotKey:
|
||||
boardsProd.permissionsService = permissionsService
|
||||
case product.BotKey:
|
||||
botService, ok := service.(product.BotService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.botService = botService
|
||||
case app.ClusterKey:
|
||||
boardsProd.botService = botService
|
||||
case product.ClusterKey:
|
||||
clusterService, ok := service.(product.ClusterService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.clusterService = clusterService
|
||||
case app.ConfigKey:
|
||||
boardsProd.clusterService = clusterService
|
||||
case product.ConfigKey:
|
||||
configService, ok := service.(product.ConfigService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.configService = configService
|
||||
case app.LogKey:
|
||||
boardsProd.configService = configService
|
||||
case product.LogKey:
|
||||
logger, ok := service.(mlog.LoggerIFace)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.logger = logger.With(mlog.String("product", boardsProductName))
|
||||
case app.LicenseKey:
|
||||
boardsProd.logger = logger.With(mlog.String("product", boardsProductName))
|
||||
case product.LicenseKey:
|
||||
licenseService, ok := service.(product.LicenseService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.licenseService = licenseService
|
||||
case app.FilestoreKey:
|
||||
boardsProd.licenseService = licenseService
|
||||
case product.FilestoreKey:
|
||||
filestoreService, ok := service.(product.FilestoreService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.filestoreService = filestoreService
|
||||
case app.FileInfoStoreKey:
|
||||
boardsProd.filestoreService = filestoreService
|
||||
case product.FileInfoStoreKey:
|
||||
fileInfoStoreService, ok := service.(product.FileInfoStoreService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.fileInfoStoreService = fileInfoStoreService
|
||||
case app.RouterKey:
|
||||
boardsProd.fileInfoStoreService = fileInfoStoreService
|
||||
case product.RouterKey:
|
||||
routerService, ok := service.(product.RouterService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.routerService = routerService
|
||||
case app.CloudKey:
|
||||
boardsProd.routerService = routerService
|
||||
case product.CloudKey:
|
||||
cloudService, ok := service.(product.CloudService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.cloudService = cloudService
|
||||
case app.KVStoreKey:
|
||||
boardsProd.cloudService = cloudService
|
||||
case product.KVStoreKey:
|
||||
kvStoreService, ok := service.(product.KVStoreService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.kvStoreService = kvStoreService
|
||||
case app.StoreKey:
|
||||
boardsProd.kvStoreService = kvStoreService
|
||||
case product.StoreKey:
|
||||
storeService, ok := service.(product.StoreService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.storeService = storeService
|
||||
case app.SystemKey:
|
||||
boardsProd.storeService = storeService
|
||||
case product.SystemKey:
|
||||
systemService, ok := service.(product.SystemService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.systemService = systemService
|
||||
case app.PreferencesKey:
|
||||
boardsProd.systemService = systemService
|
||||
case product.PreferencesKey:
|
||||
preferencesService, ok := service.(product.PreferencesService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.preferencesService = preferencesService
|
||||
case app.HooksKey:
|
||||
boardsProd.preferencesService = preferencesService
|
||||
case product.HooksKey:
|
||||
hooksService, ok := service.(product.HooksService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
return fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
|
||||
}
|
||||
boards.hooksService = hooksService
|
||||
boardsProd.hooksService = hooksService
|
||||
}
|
||||
}
|
||||
return boards, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bp *boardsProduct) Start() error {
|
||||
|
84
mattermost-plugin/server/boards/boards_service_api.go
Normal file
84
mattermost-plugin/server/boards/boards_service_api.go
Normal file
@ -0,0 +1,84 @@
|
||||
package boards
|
||||
|
||||
import (
|
||||
"github.com/mattermost/focalboard/server/app"
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/product"
|
||||
)
|
||||
|
||||
// boardsServiceAPI provides a service API for other products such as Channels.
|
||||
type boardsServiceAPI struct {
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func NewBoardsServiceAPI(app *BoardsApp) *boardsServiceAPI {
|
||||
return &boardsServiceAPI{
|
||||
app: app.server.App(),
|
||||
}
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) GetTemplates(teamID string, userID string) ([]*model.Board, error) {
|
||||
return bs.app.GetTemplateBoards(teamID, userID)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) GetBoard(boardID string) (*model.Board, error) {
|
||||
return bs.app.GetBoard(boardID)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) CreateBoard(board *model.Board, userID string, addmember bool) (*model.Board, error) {
|
||||
return bs.app.CreateBoard(board, userID, addmember)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) PatchBoard(boardPatch *model.BoardPatch, boardID string, userID string) (*model.Board, error) {
|
||||
return bs.app.PatchBoard(boardPatch, boardID, userID)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) DeleteBoard(boardID string, userID string) error {
|
||||
return bs.app.DeleteBoard(boardID, userID)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) SearchBoards(searchTerm string, searchField model.BoardSearchField,
|
||||
userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return bs.app.SearchBoardsForUser(searchTerm, searchField, userID, includePublicBoards)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) LinkBoardToChannel(boardID string, channelID string, userID string) (*model.Board, error) {
|
||||
patch := &model.BoardPatch{
|
||||
ChannelID: &channelID,
|
||||
}
|
||||
return bs.app.PatchBoard(patch, boardID, userID)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) GetCards(boardID string) ([]*model.Card, error) {
|
||||
return bs.app.GetCardsForBoard(boardID, 0, 0)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) GetCard(cardID string) (*model.Card, error) {
|
||||
return bs.app.GetCardByID(cardID)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) CreateCard(card *model.Card, boardID string, userID string) (*model.Card, error) {
|
||||
return bs.app.CreateCard(card, boardID, userID, false)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) PatchCard(cardPatch *model.CardPatch, cardID string, userID string) (*model.Card, error) {
|
||||
return bs.app.PatchCard(cardPatch, cardID, userID, false)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) DeleteCard(cardID string, userID string) error {
|
||||
return bs.app.DeleteBlock(cardID, userID)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) HasPermissionToBoard(userID, boardID string, permission *mm_model.Permission) bool {
|
||||
return bs.app.HasPermissionToBoard(userID, boardID, permission)
|
||||
}
|
||||
|
||||
func (bs *boardsServiceAPI) DuplicateBoard(boardID string, userID string,
|
||||
toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) {
|
||||
return bs.app.DuplicateBoard(boardID, userID, toTeam, asTemplate)
|
||||
}
|
||||
|
||||
// Ensure boardsServiceAPI implements product.BoardsService interface.
|
||||
var _ product.BoardsService = (*boardsServiceAPI)(nil)
|
@ -16,7 +16,6 @@ import (
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
|
||||
"github.com/mattermost/focalboard/server/services/store/sqlstore"
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
"github.com/mattermost/focalboard/server/ws"
|
||||
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
@ -85,6 +84,7 @@ func NewBoardsApp(api model.ServicesAPI) (*BoardsApp, error) {
|
||||
return cluster.NewMutex(&mutexAPIAdapter{api: api}, name)
|
||||
},
|
||||
ServicesAPI: api,
|
||||
ConfigFn: api.GetConfig,
|
||||
}
|
||||
|
||||
var db store.Store
|
||||
@ -147,16 +147,20 @@ func NewBoardsApp(api model.ServicesAPI) (*BoardsApp, error) {
|
||||
|
||||
backendParams.appAPI.init(db, server.App())
|
||||
|
||||
if utils.IsCloudLicense(api.GetLicense()) {
|
||||
limits, err := api.GetCloudLimits()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching cloud limits when starting Boards: %w", err)
|
||||
}
|
||||
// ToDo: Cloud Limits have been disabled by design. We should
|
||||
// revisit the decision and update the related code accordingly
|
||||
/*
|
||||
if utils.IsCloudLicense(api.GetLicense()) {
|
||||
limits, err := api.GetCloudLimits()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching cloud limits when starting Boards: %w", err)
|
||||
}
|
||||
|
||||
if err := server.App().SetCloudLimits(limits); err != nil {
|
||||
return nil, fmt.Errorf("error setting cloud limits when starting Boards: %w", err)
|
||||
if err := server.App().SetCloudLimits(limits); err != nil {
|
||||
return nil, fmt.Errorf("error setting cloud limits when starting Boards: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
return &BoardsApp{
|
||||
server: server,
|
||||
|
2
mattermost-plugin/server/manifest.go
generated
2
mattermost-plugin/server/manifest.go
generated
@ -20,7 +20,7 @@ const manifestStr = `
|
||||
"support_url": "https://github.com/mattermost/focalboard/issues",
|
||||
"release_notes_url": "https://github.com/mattermost/focalboard/releases",
|
||||
"icon_path": "assets/starter-template-icon.svg",
|
||||
"version": "7.7.0",
|
||||
"version": "7.8.0",
|
||||
"min_server_version": "7.2.0",
|
||||
"server": {
|
||||
"executables": {
|
||||
|
@ -31,7 +31,9 @@ exports[`components/boardSelector escape button should unmount the component 1`]
|
||||
<div
|
||||
class="toolbar--right"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
@ -134,7 +136,9 @@ exports[`components/boardSelector renders with no results 1`] = `
|
||||
<div
|
||||
class="toolbar--right"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
@ -238,7 +242,9 @@ exports[`components/boardSelector renders with some results 1`] = `
|
||||
<div
|
||||
class="toolbar--right"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
@ -289,25 +295,25 @@ exports[`components/boardSelector renders with some results 1`] = `
|
||||
<div
|
||||
class="BoardSelectorItem-info"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultLine"
|
||||
class="d-flex"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
@ -328,25 +334,25 @@ exports[`components/boardSelector renders with some results 1`] = `
|
||||
<div
|
||||
class="BoardSelectorItem-info"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultLine"
|
||||
class="d-flex"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
@ -367,25 +373,25 @@ exports[`components/boardSelector renders with some results 1`] = `
|
||||
<div
|
||||
class="BoardSelectorItem-info"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultLine"
|
||||
class="d-flex"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
@ -440,7 +446,9 @@ exports[`components/boardSelector renders without start searching 1`] = `
|
||||
<div
|
||||
class="toolbar--right"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<button
|
||||
class="Button emphasis--secondary"
|
||||
type="button"
|
||||
|
@ -8,25 +8,25 @@ exports[`components/boardSelectorItem renders board without title 1`] = `
|
||||
<div
|
||||
class="BoardSelectorItem-info"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultLine"
|
||||
class="d-flex"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Untitled board
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
@ -52,25 +52,25 @@ exports[`components/boardSelectorItem renders linked board 1`] = `
|
||||
<div
|
||||
class="BoardSelectorItem-info"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultLine"
|
||||
class="d-flex"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Test title
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
@ -96,25 +96,25 @@ exports[`components/boardSelectorItem renders not linked board 1`] = `
|
||||
<div
|
||||
class="BoardSelectorItem-info"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultLine"
|
||||
class="d-flex"
|
||||
>
|
||||
<span
|
||||
class="icon"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-product-boards"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="resultTitle"
|
||||
>
|
||||
Test title
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="resultDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="linkUnlinkButton"
|
||||
|
@ -35,7 +35,7 @@ exports[`components/rhsChannelBoardItem render board 1`] = `
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 08, 8:10 PM
|
||||
Last update at: July 08, 2022, 8:10 PM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -168,7 +168,7 @@ exports[`components/rhsChannelBoardItem render board with menu open 1`] = `
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 08, 8:10 PM
|
||||
Last update at: July 08, 2022, 8:10 PM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -64,7 +64,7 @@ exports[`components/rhsChannelBoards renders the RHS for channel boards 1`] = `
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 08, 8:10 PM
|
||||
Last update at: July 08, 2022, 8:10 PM
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@ -100,7 +100,7 @@ exports[`components/rhsChannelBoards renders the RHS for channel boards 1`] = `
|
||||
<div
|
||||
class="date"
|
||||
>
|
||||
Last update at: July 08, 8:10 PM
|
||||
Last update at: July 08, 2022, 8:10 PM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,14 +1,14 @@
|
||||
.BoardSelectorItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
flex-direction: row;
|
||||
padding: 10px 0;
|
||||
margin: 0 35px;
|
||||
padding: 10px 35px 10px 0;
|
||||
|
||||
.BoardSelectorItem-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding-left: 35px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@ -42,7 +42,8 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.7;
|
||||
padding-left: 28px;
|
||||
opacity: 0.64;
|
||||
}
|
||||
|
||||
.linkUnlinkButton {
|
||||
|
@ -25,11 +25,11 @@ const BoardSelectorItem = (props: Props) => {
|
||||
return (
|
||||
<div className='BoardSelectorItem'>
|
||||
<div className='BoardSelectorItem-info'>
|
||||
<span className='icon'>{item.icon || <CompassIcon icon='product-boards'/>}</span>
|
||||
<div className='resultLine'>
|
||||
<div className='d-flex'>
|
||||
<span className='icon'>{item.icon || <CompassIcon icon='product-boards'/>}</span>
|
||||
<div className='resultTitle'>{resultTitle}</div>
|
||||
<div className='resultDescription'>{item.description}</div>
|
||||
</div>
|
||||
<div className='resultDescription'>{item.description}</div>
|
||||
</div>
|
||||
<div className='linkUnlinkButton'>
|
||||
{item.channelId === currentChannel &&
|
||||
|
@ -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) => {
|
||||
@ -326,7 +329,7 @@ export default class Plugin {
|
||||
}
|
||||
|
||||
if (registry.registerChannelIntroButtonAction) {
|
||||
this.channelHeaderButtonId = registry.registerChannelIntroButtonAction(<FocalboardIcon/>, goToFocalboardTemplate, 'Boards')
|
||||
this.channelHeaderButtonId = registry.registerChannelIntroButtonAction(<FocalboardIcon/>, goToFocalboardTemplate, intl.formatMessage({id: 'ChannelIntro.CreateBoard', defaultMessage: 'Create a board'}))
|
||||
}
|
||||
|
||||
if (this.registry.registerAppBarComponent) {
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -114,6 +114,11 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// description: The search term. Must have at least one character
|
||||
// required: true
|
||||
// type: string
|
||||
// - name: field
|
||||
// in: query
|
||||
// description: The field to search on for search term. Can be `title`, `property_name`. Defaults to `title`
|
||||
// required: false
|
||||
// type: string
|
||||
// security:
|
||||
// - BearerAuth: []
|
||||
// responses:
|
||||
@ -128,8 +133,18 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
|
||||
// schema:
|
||||
// "$ref": "#/definitions/ErrorResponse"
|
||||
|
||||
var err error
|
||||
teamID := mux.Vars(r)["teamID"]
|
||||
term := r.URL.Query().Get("q")
|
||||
searchFieldText := r.URL.Query().Get("field")
|
||||
searchField := model.BoardSearchFieldTitle
|
||||
if searchFieldText != "" {
|
||||
searchField, err = model.BoardSearchFieldFromString(searchFieldText)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, model.NewErrBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
userID := getUserID(r)
|
||||
|
||||
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
|
||||
@ -153,7 +168,7 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.SearchBoardsForUser(term, userID, !isGuest)
|
||||
boards, err := a.app.SearchBoardsForUser(term, searchField, userID, !isGuest)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
@ -312,7 +327,7 @@ func (a *API) handleSearchAllBoards(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// retrieve boards list
|
||||
boards, err := a.app.SearchBoardsForUser(term, userID, !isGuest)
|
||||
boards, err := a.app.SearchBoardsForUser(term, model.BoardSearchFieldTitle, userID, !isGuest)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r, err)
|
||||
return
|
||||
|
@ -64,6 +64,7 @@ type App struct {
|
||||
metrics *metrics.Metrics
|
||||
notifications *notify.Service
|
||||
logger mlog.LoggerIFace
|
||||
permissions permissions.PermissionsService
|
||||
blockChangeNotifier *utils.CallbackQueue
|
||||
servicesAPI servicesAPI
|
||||
|
||||
@ -90,6 +91,7 @@ func New(config *config.Configuration, wsAdapter ws.Adapter, services Services)
|
||||
metrics: services.Metrics,
|
||||
notifications: services.Notifications,
|
||||
logger: services.Logger,
|
||||
permissions: services.Permissions,
|
||||
blockChangeNotifier: utils.NewCallbackQueue("blockChangeNotifier", blockChangeNotifierQueueSize, blockChangeNotifierPoolSize, services.Logger),
|
||||
servicesAPI: services.ServicesAPI,
|
||||
}
|
||||
|
@ -192,25 +192,32 @@ func (a *App) InsertBlockAndNotify(block *model.Block, modifiedByID string, disa
|
||||
}
|
||||
|
||||
func (a *App) isWithinViewsLimit(boardID string, block *model.Block) (bool, error) {
|
||||
limits, err := a.GetBoardsCloudLimits()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// ToDo: Cloud Limits have been disabled by design. We should
|
||||
// revisit the decision and update the related code accordingly
|
||||
|
||||
if limits.Views == model.LimitUnlimited {
|
||||
return true, nil
|
||||
}
|
||||
/*
|
||||
limits, err := a.GetBoardsCloudLimits()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
views, err := a.store.GetBlocksWithParentAndType(boardID, block.ParentID, model.TypeView)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if limits.Views == model.LimitUnlimited {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// < rather than <= because we'll be creating new view if this
|
||||
// check passes. When that view is created, the limit will be reached.
|
||||
// That's why we need to check for if existing + the being-created
|
||||
// view doesn't exceed the limit.
|
||||
return len(views) < limits.Views, nil
|
||||
views, err := a.store.GetBlocksWithParentAndType(boardID, block.ParentID, model.TypeView)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// < rather than <= because we'll be creating new view if this
|
||||
// check passes. When that view is created, the limit will be reached.
|
||||
// That's why we need to check for if existing + the being-created
|
||||
// view doesn't exceed the limit.
|
||||
return len(views) < limits.Views, nil
|
||||
*/
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *App) InsertBlocks(blocks []*model.Block, modifiedByID string) ([]*model.Block, error) {
|
||||
|
@ -78,6 +78,8 @@ func TestPatchBlocks(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("cloud limit error scenario", func(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
th.App.SetCardLimit(5)
|
||||
|
||||
fakeLicense := &mmModel.License{
|
||||
@ -185,6 +187,8 @@ func TestUndeleteBlock(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsWithinViewsLimit(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
@ -302,6 +306,8 @@ func TestInsertBlocks(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("create view within limits", func(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
boardID := testBoardID
|
||||
block := &model.Block{
|
||||
Type: model.TypeView,
|
||||
@ -334,6 +340,8 @@ func TestInsertBlocks(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("create view exceeding limits", func(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
boardID := testBoardID
|
||||
block := &model.Block{
|
||||
Type: model.TypeView,
|
||||
|
@ -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) {
|
||||
@ -189,9 +189,11 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*
|
||||
a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err))
|
||||
}
|
||||
|
||||
for _, board := range bab.Boards {
|
||||
if categoryErr := a.setBoardCategoryFromSource(boardID, board.ID, userID, toTeam, asTemplate); categoryErr != nil {
|
||||
return nil, nil, categoryErr
|
||||
if !asTemplate {
|
||||
for _, board := range bab.Boards {
|
||||
if categoryErr := a.setBoardCategoryFromSource(boardID, board.ID, userID, toTeam, asTemplate); categoryErr != nil {
|
||||
return nil, nil, categoryErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -327,10 +329,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
|
||||
@ -378,10 +383,14 @@ func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*mode
|
||||
}
|
||||
|
||||
boardLink := utils.MakeBoardLink(a.config.ServerRoot, updatedBoard.TeamID, updatedBoard.ID)
|
||||
title := updatedBoard.Title
|
||||
if title == "" {
|
||||
title = "Untitled board" // todo: localize this when server has i18n
|
||||
}
|
||||
if *patch.ChannelID != "" {
|
||||
a.postChannelMessage(fmt.Sprintf(linkBoardMessage, username, updatedBoard.Title, boardLink), updatedBoard.ChannelID)
|
||||
a.postChannelMessage(fmt.Sprintf(linkBoardMessage, username, title, boardLink), updatedBoard.ChannelID)
|
||||
} else if *patch.ChannelID == "" {
|
||||
a.postChannelMessage(fmt.Sprintf(unlinkBoardMessage, username, updatedBoard.Title, boardLink), oldChannelID)
|
||||
a.postChannelMessage(fmt.Sprintf(unlinkBoardMessage, username, title, boardLink), oldChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -634,8 +643,8 @@ func (a *App) DeleteBoardMember(boardID, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUser(term, userID, includePublicBoards)
|
||||
func (a *App) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return a.store.SearchBoardsForUser(term, searchField, userID, includePublicBoards)
|
||||
}
|
||||
|
||||
func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) {
|
||||
|
@ -52,7 +52,7 @@ func TestAddMemberToBoard(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", "board_id_1").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "default_category_id"}).Return(nil)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
@ -126,7 +126,7 @@ func TestAddMemberToBoard(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", "board_id_1").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "default_category_id"}).Return(nil)
|
||||
|
||||
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
|
||||
require.NoError(t, err)
|
||||
@ -434,9 +434,12 @@ func TestBoardCategory(t *testing.T) {
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_1").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_2").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_3").Return(nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{
|
||||
"board_id_1": "default_category_id",
|
||||
"board_id_2": "default_category_id",
|
||||
"board_id_3": "default_category_id",
|
||||
}).Return(nil)
|
||||
|
||||
boards := []*model.Board{
|
||||
{ID: "board_id_1"},
|
||||
@ -449,3 +452,90 @@ 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)
|
||||
})
|
||||
|
||||
t.Run("duplicating board as template should not set it's category", 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", true).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)
|
||||
|
||||
// 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", true)
|
||||
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,10 @@ import (
|
||||
|
||||
const defaultCategoryBoards = "Boards"
|
||||
|
||||
var errCategoryBoardsLengthMismatch = errors.New("cannot update category boards order, passed list of categories boards different size than in database")
|
||||
var errBoardNotFoundInCategory = errors.New("specified board ID not found in specified category ID")
|
||||
var errBoardMembershipNotFound = errors.New("board membership not found for user's board")
|
||||
|
||||
func (a *App) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) {
|
||||
categoryBoards, err := a.store.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
@ -53,6 +58,7 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
|
||||
TeamID: teamID,
|
||||
Collapsed: false,
|
||||
Type: model.CategoryTypeSystem,
|
||||
SortOrder: len(existingCategoryBoards) * model.CategoryBoardsSortOrderGap,
|
||||
}
|
||||
createdCategory, err := a.CreateCategory(&category)
|
||||
if err != nil {
|
||||
@ -61,23 +67,40 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
|
||||
|
||||
// once the category is created, we need to move all boards which do not
|
||||
// belong to any category, into this category.
|
||||
|
||||
boardMembers, err := a.GetMembersForUser(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("createBoardsCategory error fetching user's board memberships: %w", err)
|
||||
}
|
||||
|
||||
boardMemberByBoardID := map[string]*model.BoardMember{}
|
||||
for _, boardMember := range boardMembers {
|
||||
boardMemberByBoardID[boardMember.BoardID] = boardMember
|
||||
}
|
||||
|
||||
createdCategoryBoards := &model.CategoryBoards{
|
||||
Category: *createdCategory,
|
||||
BoardIDs: []string{},
|
||||
}
|
||||
|
||||
for _, bm := range boardMembers {
|
||||
// get user's current team's baords
|
||||
userTeamBoards, err := a.GetBoardsForUserAndTeam(userID, teamID, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("createBoardsCategory error fetching user's team's boards: %w", err)
|
||||
}
|
||||
|
||||
for _, board := range userTeamBoards {
|
||||
boardMembership, ok := boardMemberByBoardID[board.ID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("createBoardsCategory: %w", errBoardMembershipNotFound)
|
||||
}
|
||||
|
||||
// boards with implicit access (aka synthetic membership),
|
||||
// should show up in LHS only when openign them explicitelly.
|
||||
// So we don't process any synthetic membership boards
|
||||
// and only add boards with explicit access to, to the the LHS,
|
||||
// for example, if a user explicitelly added another user to a board.
|
||||
if bm.Synthetic {
|
||||
if boardMembership.Synthetic {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -85,7 +108,7 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
|
||||
|
||||
for _, categoryBoard := range existingCategoryBoards {
|
||||
for _, boardID := range categoryBoard.BoardIDs {
|
||||
if boardID == bm.BoardID {
|
||||
if boardID == board.ID {
|
||||
belongsToCategory = true
|
||||
break
|
||||
}
|
||||
@ -99,36 +122,111 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
|
||||
}
|
||||
|
||||
if !belongsToCategory {
|
||||
if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, bm.BoardID); err != nil {
|
||||
// ToDo: por siaca
|
||||
// if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, board.ID); err != nil {
|
||||
// ---
|
||||
if err := a.AddUpdateUserCategoryBoard(teamID, userID, map[string]string{board.ID: createdCategory.ID}); err != nil {
|
||||
return nil, fmt.Errorf("createBoardsCategory failed to add category-less board to the default category, defaultCategoryID: %s, error: %w", createdCategory.ID, err)
|
||||
}
|
||||
|
||||
createdCategoryBoards.BoardIDs = append(createdCategoryBoards.BoardIDs, bm.BoardID)
|
||||
createdCategoryBoards.BoardIDs = append(createdCategoryBoards.BoardIDs, board.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return createdCategoryBoards, nil
|
||||
}
|
||||
|
||||
func (a *App) AddUpdateUserCategoryBoard(teamID, userID, categoryID, boardID string) error {
|
||||
err := a.store.AddUpdateCategoryBoard(userID, categoryID, boardID)
|
||||
func (a *App) AddUpdateUserCategoryBoard(teamID, userID string, boardCategoryMapping map[string]string) error {
|
||||
err := a.store.AddUpdateCategoryBoard(userID, boardCategoryMapping)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wsPayload := make([]*model.BoardCategoryWebsocketData, len(boardCategoryMapping))
|
||||
i := 0
|
||||
for boardID, categoryID := range boardCategoryMapping {
|
||||
wsPayload[i] = &model.BoardCategoryWebsocketData{
|
||||
BoardID: boardID,
|
||||
CategoryID: categoryID,
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
a.blockChangeNotifier.Enqueue(func() error {
|
||||
a.wsAdapter.BroadcastCategoryBoardChange(
|
||||
teamID,
|
||||
userID,
|
||||
model.BoardCategoryWebsocketData{
|
||||
BoardID: boardID,
|
||||
CategoryID: categoryID,
|
||||
})
|
||||
wsPayload,
|
||||
)
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ReorderCategoryBoards(userID, teamID, categoryID string, newBoardsOrder []string) ([]string, error) {
|
||||
if err := a.verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID, newBoardsOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newOrder, err := a.store.ReorderCategoryBoards(categoryID, newBoardsOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
a.wsAdapter.BroadcastCategoryBoardsReorder(teamID, userID, categoryID, newOrder)
|
||||
}()
|
||||
|
||||
return newOrder, nil
|
||||
}
|
||||
|
||||
func (a *App) verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID string, newBoardsOrder []string) error {
|
||||
// this function is to ensure that we don't miss specifying
|
||||
// all boards of the category while reordering.
|
||||
existingCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var targetCategoryBoards *model.CategoryBoards
|
||||
for i := range existingCategoryBoards {
|
||||
if existingCategoryBoards[i].Category.ID == categoryID {
|
||||
targetCategoryBoards = &existingCategoryBoards[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetCategoryBoards == nil {
|
||||
return fmt.Errorf("%w categoryID: %s", errCategoryNotFound, categoryID)
|
||||
}
|
||||
|
||||
if len(targetCategoryBoards.BoardIDs) != len(newBoardsOrder) {
|
||||
return fmt.Errorf(
|
||||
"%w length new category boards: %d, length existing category boards: %d, userID: %s, teamID: %s, categoryID: %s",
|
||||
errCategoryBoardsLengthMismatch,
|
||||
len(newBoardsOrder),
|
||||
len(targetCategoryBoards.BoardIDs),
|
||||
userID,
|
||||
teamID,
|
||||
categoryID,
|
||||
)
|
||||
}
|
||||
|
||||
existingBoardMap := map[string]bool{}
|
||||
for _, boardID := range targetCategoryBoards.BoardIDs {
|
||||
existingBoardMap[boardID] = true
|
||||
}
|
||||
|
||||
for _, boardID := range newBoardsOrder {
|
||||
if _, found := existingBoardMap[boardID]; !found {
|
||||
return fmt.Errorf(
|
||||
"%w board ID: %s, category ID: %s, userID: %s, teamID: %s",
|
||||
errBoardNotFoundInCategory,
|
||||
boardID,
|
||||
categoryID,
|
||||
userID,
|
||||
teamID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -21,6 +21,20 @@ func TestGetUserCategoryBoards(t *testing.T) {
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board_id_1",
|
||||
}
|
||||
|
||||
board2 := &model.Board{
|
||||
ID: "board_id_2",
|
||||
}
|
||||
|
||||
board3 := &model.Board{
|
||||
ID: "board_id_3",
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
@ -35,10 +49,9 @@ func TestGetUserCategoryBoards(t *testing.T) {
|
||||
Synthetic: false,
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_1").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_2").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_3").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_2": "boards_category_id"}).Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_3": "boards_category_id"}).Return(nil)
|
||||
|
||||
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
|
||||
assert.NoError(t, err)
|
||||
@ -59,6 +72,7 @@ func TestGetUserCategoryBoards(t *testing.T) {
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
|
||||
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
|
||||
assert.NoError(t, err)
|
||||
@ -94,6 +108,7 @@ func TestCreateBoardsCategory(t *testing.T) {
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
|
||||
existingCategoryBoards := []model.CategoryBoards{}
|
||||
@ -111,6 +126,7 @@ func TestCreateBoardsCategory(t *testing.T) {
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
@ -144,6 +160,17 @@ func TestCreateBoardsCategory(t *testing.T) {
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board_id_1",
|
||||
}
|
||||
board2 := &model.Board{
|
||||
ID: "board_id_2",
|
||||
}
|
||||
board3 := &model.Board{
|
||||
ID: "board_id_3",
|
||||
}
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
@ -158,9 +185,9 @@ func TestCreateBoardsCategory(t *testing.T) {
|
||||
Synthetic: false,
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_1").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_2").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_3").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_2": "boards_category_id"}).Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_3": "boards_category_id"}).Return(nil)
|
||||
|
||||
existingCategoryBoards := []model.CategoryBoards{}
|
||||
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
|
||||
@ -180,6 +207,11 @@ func TestCreateBoardsCategory(t *testing.T) {
|
||||
Type: "system",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board_id_1",
|
||||
}
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
|
||||
{
|
||||
BoardID: "board_id_1",
|
||||
@ -194,7 +226,7 @@ func TestCreateBoardsCategory(t *testing.T) {
|
||||
Synthetic: true,
|
||||
},
|
||||
}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_1").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
|
||||
|
||||
existingCategoryBoards := []model.CategoryBoards{}
|
||||
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
|
||||
@ -208,3 +240,54 @@ func TestCreateBoardsCategory(t *testing.T) {
|
||||
assert.Equal(t, 1, len(boardsCategory.BoardIDs))
|
||||
})
|
||||
}
|
||||
|
||||
func TestReorderCategoryBoards(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
|
||||
BoardIDs: []string{"board_id_1", "board_id_2"},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
|
||||
BoardIDs: []string{"board_id_3"},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
|
||||
BoardIDs: []string{},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().ReorderCategoryBoards("category_id_1", []string{"board_id_2", "board_id_1"}).Return([]string{"board_id_2", "board_id_1"}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(newOrder))
|
||||
assert.Equal(t, "board_id_2", newOrder[0])
|
||||
assert.Equal(t, "board_id_1", newOrder[1])
|
||||
})
|
||||
|
||||
t.Run("not specifying all boards", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
|
||||
BoardIDs: []string{"board_id_1", "board_id_2", "board_id_3"},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
|
||||
BoardIDs: []string{"board_id_3"},
|
||||
},
|
||||
{
|
||||
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
|
||||
BoardIDs: []string{},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, newOrder)
|
||||
})
|
||||
}
|
||||
|
@ -92,6 +92,14 @@ func TestUpdateCategory(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("updating invalid category", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Category",
|
||||
TeamID: "team_id_1",
|
||||
UserID: "user_id_1",
|
||||
Type: "custom",
|
||||
}, nil)
|
||||
|
||||
category := &model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Name",
|
||||
@ -260,6 +268,33 @@ func TestDeleteCategory(t *testing.T) {
|
||||
DeleteAt: 10000,
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_default",
|
||||
DeleteAt: 0,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "default",
|
||||
Name: "Boards",
|
||||
},
|
||||
BoardIDs: []string{},
|
||||
},
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_1",
|
||||
DeleteAt: 0,
|
||||
UserID: "user_id_1",
|
||||
TeamID: "team_id_1",
|
||||
Type: "custom",
|
||||
Name: "Category 1",
|
||||
},
|
||||
BoardIDs: []string{},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", utils.Anything).Return(nil)
|
||||
|
||||
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
|
||||
assert.NotNil(t, deletedCategory)
|
||||
assert.NoError(t, err)
|
||||
@ -293,3 +328,171 @@ func TestDeleteCategory(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMoveBoardsToDefaultCategory(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("When default category already exists", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
},
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_2",
|
||||
Name: "Custom Category 1",
|
||||
Type: "custom",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", utils.Anything).Return(nil)
|
||||
|
||||
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("When default category doesn't already exists", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
|
||||
{
|
||||
Category: model.Category{
|
||||
ID: "category_id_2",
|
||||
Name: "Custom Category 1",
|
||||
Type: "custom",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
|
||||
ID: "default_category_id",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", utils.Anything).Return(nil)
|
||||
|
||||
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReorderCategories(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
th.Store.EXPECT().ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"}).
|
||||
Return([]string{"category_id_2", "category_id_3", "category_id_1"}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, len(newOrder))
|
||||
})
|
||||
|
||||
t.Run("not specifying all categories should fail", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3"})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, newOrder)
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyNewCategoriesMatchExisting(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("base case", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{
|
||||
"category_id_2",
|
||||
"category_id_3",
|
||||
"category_id_1",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("different category counts", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
|
||||
{
|
||||
ID: "category_id_1",
|
||||
Name: "Boards",
|
||||
Type: "system",
|
||||
},
|
||||
{
|
||||
ID: "category_id_2",
|
||||
Name: "Category 2",
|
||||
Type: "custom",
|
||||
},
|
||||
{
|
||||
ID: "category_id_3",
|
||||
Name: "Category 3",
|
||||
Type: "custom",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{
|
||||
"category_id_2",
|
||||
"category_id_3",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
@ -20,39 +20,45 @@ var ErrNilPluginAPI = errors.New("server not running in plugin mode")
|
||||
// GetBoardsCloudLimits returns the limits of the server, and an empty
|
||||
// limits struct if there are no limits set.
|
||||
func (a *App) GetBoardsCloudLimits() (*model.BoardsCloudLimits, error) {
|
||||
if !a.IsCloud() {
|
||||
return &model.BoardsCloudLimits{}, nil
|
||||
}
|
||||
|
||||
productLimits, err := a.store.GetCloudLimits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
usedCards, err := a.store.GetUsedCardsCount()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boardsCloudLimits := &model.BoardsCloudLimits{
|
||||
UsedCards: usedCards,
|
||||
CardLimitTimestamp: cardLimitTimestamp,
|
||||
}
|
||||
if productLimits != nil && productLimits.Boards != nil {
|
||||
if productLimits.Boards.Cards != nil {
|
||||
boardsCloudLimits.Cards = *productLimits.Boards.Cards
|
||||
// ToDo: Cloud Limits have been disabled by design. We should
|
||||
// revisit the decision and update the related code accordingly
|
||||
/*
|
||||
if !a.IsCloud() {
|
||||
return &model.BoardsCloudLimits{}, nil
|
||||
}
|
||||
if productLimits.Boards.Views != nil {
|
||||
boardsCloudLimits.Views = *productLimits.Boards.Views
|
||||
}
|
||||
}
|
||||
|
||||
return boardsCloudLimits, nil
|
||||
productLimits, err := a.store.GetCloudLimits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
usedCards, err := a.store.GetUsedCardsCount()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cardLimitTimestamp, err := a.store.GetCardLimitTimestamp()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
boardsCloudLimits := &model.BoardsCloudLimits{
|
||||
UsedCards: usedCards,
|
||||
CardLimitTimestamp: cardLimitTimestamp,
|
||||
}
|
||||
if productLimits != nil && productLimits.Boards != nil {
|
||||
if productLimits.Boards.Cards != nil {
|
||||
boardsCloudLimits.Cards = *productLimits.Boards.Cards
|
||||
}
|
||||
if productLimits.Boards.Views != nil {
|
||||
boardsCloudLimits.Views = *productLimits.Boards.Views
|
||||
}
|
||||
}
|
||||
|
||||
return boardsCloudLimits, nil
|
||||
*/
|
||||
|
||||
return &model.BoardsCloudLimits{}, nil
|
||||
}
|
||||
|
||||
func (a *App) GetUsedCardsCount() (int, error) {
|
||||
@ -68,7 +74,12 @@ func (a *App) IsCloud() bool {
|
||||
// IsCloudLimited returns true if the server is running in cloud mode
|
||||
// and the card limit has been set.
|
||||
func (a *App) IsCloudLimited() bool {
|
||||
return a.CardLimit() != 0 && a.IsCloud()
|
||||
// ToDo: Cloud Limits have been disabled by design. We should
|
||||
// revisit the decision and update the related code accordingly
|
||||
|
||||
// return a.CardLimit() != 0 && a.IsCloud()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetCloudLimits sets the limits of the server.
|
||||
|
@ -68,6 +68,8 @@ func TestIsCloud(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsCloudLimited(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
t.Run("if no limit has been set, it should be false", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
@ -91,6 +93,8 @@ func TestIsCloudLimited(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSetCloudLimits(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
t.Run("if the limits are empty, it should do nothing", func(t *testing.T) {
|
||||
t.Run("limits empty", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
@ -179,6 +183,8 @@ func TestSetCloudLimits(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateCardLimitTimestamp(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
fakeLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
|
||||
}
|
||||
@ -215,6 +221,8 @@ func TestUpdateCardLimitTimestamp(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetTemplateMapForBlocks(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
t.Run("should fetch the necessary boards from the database", func(t *testing.T) {
|
||||
th, tearDown := SetupTestHelper(t)
|
||||
defer tearDown()
|
||||
@ -301,6 +309,8 @@ func TestGetTemplateMapForBlocks(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApplyCloudLimits(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
fakeLicense := &mmModel.License{
|
||||
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
|
||||
}
|
||||
@ -395,6 +405,8 @@ func TestApplyCloudLimits(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContainsLimitedBlocks(t *testing.T) {
|
||||
t.Skipf("The Cloud Limits feature has been disabled")
|
||||
|
||||
// for all the following tests, the timestamp will be set to 150,
|
||||
// which means that blocks with an UpdateAt set to 100 will be
|
||||
// outside the active window and possibly limited, and blocks with
|
||||
|
@ -55,8 +55,9 @@ func TestApp_ImportArchive(t *testing.T) {
|
||||
ID: "boards_category_id",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user", "test-team", false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().GetMembersForUser("user").Return([]*model.BoardMember{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user", "boards_category_id", utils.Anything).Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user", utils.Anything).Return(nil)
|
||||
|
||||
err := th.App.ImportArchive(r, opts)
|
||||
require.NoError(t, err, "import archive should not fail")
|
||||
|
@ -77,7 +77,8 @@ func TestPrepareOnboardingTour(t *testing.T) {
|
||||
ID: "boards_category",
|
||||
Name: "Boards",
|
||||
}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", "board_id_2").Return(nil)
|
||||
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id_1", teamID, false).Return([]*model.Board{}, nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_2": "boards_category_id"}).Return(nil)
|
||||
|
||||
teamID, boardID, err := th.App.PrepareOnboardingTour(userID, teamID)
|
||||
assert.NoError(t, err)
|
||||
@ -120,7 +121,7 @@ func TestCreateWelcomeBoard(t *testing.T) {
|
||||
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
|
||||
},
|
||||
}, nil).Times(2)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", "board_id_1").Return(nil)
|
||||
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
|
||||
|
||||
boardID, err := th.App.createWelcomeBoard(userID, teamID)
|
||||
assert.Nil(t, err)
|
||||
|
9
server/app/permissions.go
Normal file
9
server/app/permissions.go
Normal file
@ -0,0 +1,9 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
mm_model "github.com/mattermost/mattermost-server/v6/model"
|
||||
)
|
||||
|
||||
func (a *App) HasPermissionToBoard(userID, boardID string, permission *mm_model.Permission) bool {
|
||||
return a.permissions.HasPermissionToBoard(userID, boardID, permission)
|
||||
}
|
@ -443,6 +443,15 @@ func (c *Client) CreateCategory(category model.Category) (*model.Category, *Resp
|
||||
return model.CategoryFromJSON(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteCategory(teamID, categoryID string) *Response {
|
||||
r, err := c.DoAPIDelete(c.GetTeamRoute(teamID)+"/categories/"+categoryID, "")
|
||||
if err != nil {
|
||||
return BuildErrorResponse(r, err)
|
||||
}
|
||||
|
||||
return BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) UpdateCategoryBoard(teamID, categoryID, boardID string) *Response {
|
||||
r, err := c.DoAPIPost(fmt.Sprintf("%s/categories/%s/boards/%s", c.GetTeamRoute(teamID), categoryID, boardID), "")
|
||||
if err != nil {
|
||||
@ -465,6 +474,30 @@ func (c *Client) GetUserCategoryBoards(teamID string) ([]model.CategoryBoards, *
|
||||
return categoryBoards, BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) ReorderCategories(teamID string, newOrder []string) ([]string, *Response) {
|
||||
r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/reorder", toJSON(newOrder))
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
var updatedCategoryOrder []string
|
||||
_ = json.NewDecoder(r.Body).Decode(&updatedCategoryOrder)
|
||||
return updatedCategoryOrder, BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) ReorderCategoryBoards(teamID, categoryID string, newOrder []string) ([]string, *Response) {
|
||||
r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/"+categoryID+"/reorder", toJSON(newOrder))
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
var updatedBoardsOrder []string
|
||||
_ = json.NewDecoder(r.Body).Decode(&updatedBoardsOrder)
|
||||
return updatedBoardsOrder, BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks) (*model.BoardsAndBlocks, *Response) {
|
||||
r, err := c.DoAPIPatch(c.GetBoardsAndBlocksRoute(), toJSON(pbab))
|
||||
if err != nil {
|
||||
@ -687,6 +720,17 @@ func (c *Client) GetBoardsForTeam(teamID string) ([]*model.Board, *Response) {
|
||||
return model.BoardsFromJSON(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) SearchBoardsForUser(teamID, term string, field model.BoardSearchField) ([]*model.Board, *Response) {
|
||||
query := fmt.Sprintf("q=%s&field=%s", term, field)
|
||||
r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/search?"+query, "")
|
||||
if err != nil {
|
||||
return nil, BuildErrorResponse(r, err)
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
return model.BoardsFromJSON(r.Body), BuildResponse(r)
|
||||
}
|
||||
|
||||
func (c *Client) SearchBoardsForTeam(teamID, term string) ([]*model.Board, *Response) {
|
||||
r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/search?q="+term, "")
|
||||
if err != nil {
|
||||
|
@ -10,7 +10,7 @@ require (
|
||||
github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94
|
||||
github.com/lib/pq v1.10.7
|
||||
github.com/mattermost/mattermost-plugin-api v0.0.29-0.20220801143717-73008cfda2fb
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221130200243-06e964b86b0d
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221214122404-8d90c7042f93
|
||||
github.com/mattermost/morph v1.0.5-0.20221115094356-4c18a75b1f5e
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/mgdelacroix/foundation v0.0.0-20220812143423-0bfc18f73538
|
||||
|
@ -875,6 +875,8 @@ github.com/mattermost/mattermost-server/v6 v6.0.0-20220802151854-f07c31c5d933 h1
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20220802151854-f07c31c5d933/go.mod h1:otnBnKY9Y0eNkUKeD161de+BUBlESwANTnrkPT/392Y=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221130200243-06e964b86b0d h1:CKJXDUCkRrfy1U9sZHOpvACOtkthV5iWt2boHUK720I=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221130200243-06e964b86b0d/go.mod h1:U3gSM0I15WSMHPpDEU30mmc4JrbSDk+8F1+MFLOHWD0=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221214122404-8d90c7042f93 h1:mGN2D6KhjKosQdZ+BHzmWxsA/tRK9FiR+nUd38nSZQY=
|
||||
github.com/mattermost/mattermost-server/v6 v6.0.0-20221214122404-8d90c7042f93/go.mod h1:U3gSM0I15WSMHPpDEU30mmc4JrbSDk+8F1+MFLOHWD0=
|
||||
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8 h1:gwliVjCTqAC01mSCNqa5nJ/4MmGq50vrjsottIhQ4d8=
|
||||
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8/go.mod h1:jxM3g1bx+k2Thz7jofcHguBS8TZn5Pc+o5MGmORObhw=
|
||||
github.com/mattermost/morph v1.0.5-0.20221115094356-4c18a75b1f5e h1:VfNz+fvJ3DxOlALM22Eea8ONp5jHrybKBCcCtDPVlss=
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -271,8 +271,8 @@ func (s *PluginTestStore) GetChannel(teamID, channel string) (*mmModel.Channel,
|
||||
return nil, errTestStore
|
||||
}
|
||||
|
||||
func (s *PluginTestStore) SearchBoardsForUser(term string, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
boards, err := s.Store.SearchBoardsForUser(term, userID, includePublicBoards)
|
||||
func (s *PluginTestStore) SearchBoardsForUser(term string, field model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
boards, err := s.Store.SearchBoardsForUser(term, field, userID, includePublicBoards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
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)
|
||||
}
|
@ -8,6 +8,7 @@ import (
|
||||
|
||||
type BoardType string
|
||||
type BoardRole string
|
||||
type BoardSearchField string
|
||||
|
||||
const (
|
||||
BoardTypeOpen BoardType = "O"
|
||||
@ -22,6 +23,12 @@ const (
|
||||
BoardRoleAdmin BoardRole = "admin"
|
||||
)
|
||||
|
||||
const (
|
||||
BoardSearchFieldNone BoardSearchField = ""
|
||||
BoardSearchFieldTitle BoardSearchField = "title"
|
||||
BoardSearchFieldPropertyName BoardSearchField = "property_name"
|
||||
)
|
||||
|
||||
// Board groups a set of blocks and its layout
|
||||
// swagger:model
|
||||
type Board struct {
|
||||
@ -98,6 +105,21 @@ type Board struct {
|
||||
DeleteAt int64 `json:"deleteAt"`
|
||||
}
|
||||
|
||||
// GetPropertyString returns the value of the specified property as a string,
|
||||
// or error if the property does not exist or is not of type string.
|
||||
func (b *Board) GetPropertyString(propName string) (string, error) {
|
||||
val, ok := b.Properties[propName]
|
||||
if !ok {
|
||||
return "", NewErrNotFound(propName)
|
||||
}
|
||||
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return "", ErrInvalidPropertyValueType
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// BoardPatch is a patch for modify boards
|
||||
// swagger:model
|
||||
type BoardPatch struct {
|
||||
@ -392,3 +414,13 @@ type BoardMemberHistoryEntry struct {
|
||||
// required: true
|
||||
InsertAt time.Time `json:"insertAt"`
|
||||
}
|
||||
|
||||
func BoardSearchFieldFromString(field string) (BoardSearchField, error) {
|
||||
switch field {
|
||||
case string(BoardSearchFieldTitle):
|
||||
return BoardSearchFieldTitle, nil
|
||||
case string(BoardSearchFieldPropertyName):
|
||||
return BoardSearchFieldPropertyName, nil
|
||||
}
|
||||
return BoardSearchFieldNone, ErrInvalidBoardSearchField
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -24,6 +24,8 @@ var (
|
||||
ErrBoardMemberIsLastAdmin = errors.New("cannot leave a board with no admins")
|
||||
|
||||
ErrRequestEntityTooLarge = errors.New("request entity too large")
|
||||
|
||||
ErrInvalidBoardSearchField = errors.New("invalid board search field")
|
||||
)
|
||||
|
||||
// ErrNotFound is an error type that can be returned by store APIs
|
||||
|
@ -98,6 +98,32 @@ func (pd PropDef) GetValue(v interface{}, resolver PropValueResolver) (string, e
|
||||
}
|
||||
return userID, nil
|
||||
|
||||
case "multiPerson":
|
||||
// v is a slice of user IDs
|
||||
userIDs, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("multiPerson property type: %w", ErrInvalidPropertyValueType)
|
||||
}
|
||||
if resolver != nil {
|
||||
usernames := make([]string, len(userIDs))
|
||||
|
||||
for i, userIDInterface := range userIDs {
|
||||
userID := userIDInterface.(string)
|
||||
|
||||
user, err := resolver.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if user == nil {
|
||||
usernames[i] = userID
|
||||
} else {
|
||||
usernames[i] = user.Username
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(usernames, ", "), nil
|
||||
}
|
||||
|
||||
case "multiSelect":
|
||||
// v is a slice of strings containing option ids
|
||||
ms, ok := v.([]interface{})
|
||||
|
@ -12,6 +12,24 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type MockResolver struct{}
|
||||
|
||||
func (r MockResolver) GetUserByID(userID string) (*User, error) {
|
||||
if userID == "user_id_1" {
|
||||
return &User{
|
||||
ID: "user_id_1",
|
||||
Username: "username_1",
|
||||
}, nil
|
||||
} else if userID == "user_id_2" {
|
||||
return &User{
|
||||
ID: "user_id_2",
|
||||
Username: "username_2",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func Test_parsePropertySchema(t *testing.T) {
|
||||
board := &Board{
|
||||
ID: utils.NewID(utils.IDTypeBoard),
|
||||
@ -44,6 +62,33 @@ func Test_parsePropertySchema(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func Test_GetValue(t *testing.T) {
|
||||
resolver := MockResolver{}
|
||||
|
||||
propDef := PropDef{
|
||||
Type: "multiPerson",
|
||||
}
|
||||
|
||||
value, err := propDef.GetValue([]interface{}{"user_id_1", "user_id_2"}, resolver)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "username_1, username_2", value)
|
||||
|
||||
// trying with only user
|
||||
value, err = propDef.GetValue([]interface{}{"user_id_1"}, resolver)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "username_1", value)
|
||||
|
||||
// trying with unknown user
|
||||
value, err = propDef.GetValue([]interface{}{"user_id_1", "user_id_unknown"}, resolver)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "username_1, user_id_unknown", value)
|
||||
|
||||
// trying with multiple unknown users
|
||||
value, err = propDef.GetValue([]interface{}{"michael_scott", "jim_halpert"}, resolver)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "michael_scott, jim_halpert", value)
|
||||
}
|
||||
|
||||
const (
|
||||
cardPropertiesExample = `[
|
||||
{
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
// It should be maintained in chronological order with most current
|
||||
// release at the front of the list.
|
||||
var versions = []string{
|
||||
"7.8.0",
|
||||
"7.7.0",
|
||||
"7.6.0",
|
||||
"7.5.0",
|
||||
|
@ -261,6 +261,21 @@ func (mr *MockAPIMockRecorder) CreateTeamMembersGracefully(arg0, arg1, arg2 inte
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeamMembersGracefully", reflect.TypeOf((*MockAPI)(nil).CreateTeamMembersGracefully), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// CreateUploadSession mocks base method.
|
||||
func (m *MockAPI) CreateUploadSession(arg0 *model.UploadSession) (*model.UploadSession, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateUploadSession", arg0)
|
||||
ret0, _ := ret[0].(*model.UploadSession)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateUploadSession indicates an expected call of CreateUploadSession.
|
||||
func (mr *MockAPIMockRecorder) CreateUploadSession(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUploadSession", reflect.TypeOf((*MockAPI)(nil).CreateUploadSession), arg0)
|
||||
}
|
||||
|
||||
// CreateUser mocks base method.
|
||||
func (m *MockAPI) CreateUser(arg0 *model.User) (*model.User, *model.AppError) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -1440,6 +1455,21 @@ func (mr *MockAPIMockRecorder) GetUnsanitizedConfig() *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnsanitizedConfig", reflect.TypeOf((*MockAPI)(nil).GetUnsanitizedConfig))
|
||||
}
|
||||
|
||||
// GetUploadSession mocks base method.
|
||||
func (m *MockAPI) GetUploadSession(arg0 string) (*model.UploadSession, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUploadSession", arg0)
|
||||
ret0, _ := ret[0].(*model.UploadSession)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUploadSession indicates an expected call of GetUploadSession.
|
||||
func (mr *MockAPIMockRecorder) GetUploadSession(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUploadSession", reflect.TypeOf((*MockAPI)(nil).GetUploadSession), arg0)
|
||||
}
|
||||
|
||||
// GetUser mocks base method.
|
||||
func (m *MockAPI) GetUser(arg0 string) (*model.User, *model.AppError) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -2031,6 +2061,20 @@ func (mr *MockAPIMockRecorder) ReadFile(arg0 interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockAPI)(nil).ReadFile), arg0)
|
||||
}
|
||||
|
||||
// RegisterCollectionAndTopic mocks base method.
|
||||
func (m *MockAPI) RegisterCollectionAndTopic(arg0, arg1 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RegisterCollectionAndTopic", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// RegisterCollectionAndTopic indicates an expected call of RegisterCollectionAndTopic.
|
||||
func (mr *MockAPIMockRecorder) RegisterCollectionAndTopic(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterCollectionAndTopic", reflect.TypeOf((*MockAPI)(nil).RegisterCollectionAndTopic), arg0, arg1)
|
||||
}
|
||||
|
||||
// RegisterCommand mocks base method.
|
||||
func (m *MockAPI) RegisterCommand(arg0 *model.Command) error {
|
||||
m.ctrl.T.Helper()
|
||||
@ -2581,6 +2625,21 @@ func (mr *MockAPIMockRecorder) UpdateUserStatus(arg0, arg1 interface{}) *gomock.
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserStatus", reflect.TypeOf((*MockAPI)(nil).UpdateUserStatus), arg0, arg1)
|
||||
}
|
||||
|
||||
// UploadData mocks base method.
|
||||
func (m *MockAPI) UploadData(arg0 *model.UploadSession, arg1 io.Reader) (*model.FileInfo, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UploadData", arg0, arg1)
|
||||
ret0, _ := ret[0].(*model.FileInfo)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UploadData indicates an expected call of UploadData.
|
||||
func (mr *MockAPIMockRecorder) UploadData(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadData", reflect.TypeOf((*MockAPI)(nil).UploadData), arg0, arg1)
|
||||
}
|
||||
|
||||
// UploadFile mocks base method.
|
||||
func (m *MockAPI) UploadFile(arg0 []byte, arg1, arg2 string) (*model.FileInfo, *model.AppError) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -670,77 +670,129 @@ func (s *MattermostAuthLayer) baseUserQuery(showEmail, showName bool) sq.SelectB
|
||||
// term that are either private and which the user is a member of, or
|
||||
// they're open, regardless of the user membership.
|
||||
// Search is case-insensitive.
|
||||
func (s *MattermostAuthLayer) SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder().
|
||||
func (s *MattermostAuthLayer) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
// as we're joining three queries, we need to avoid numbered
|
||||
// placeholders until the join is done, so we use the default
|
||||
// question mark placeholder here
|
||||
builder := s.getQueryBuilder().PlaceholderFormat(sq.Question)
|
||||
|
||||
boardMembersQ := builder.
|
||||
Select(boardFields("b.")...).
|
||||
From(s.tablePrefix + "boards as b").
|
||||
LeftJoin(s.tablePrefix + "board_members as bm on b.id=bm.board_id").
|
||||
LeftJoin("TeamMembers as tm on tm.teamid=b.team_id").
|
||||
LeftJoin("ChannelMembers as cm on cm.channelId=b.channel_id").
|
||||
Where(sq.Eq{"b.is_template": false}).
|
||||
Where(sq.Eq{"tm.userID": userID}).
|
||||
Where(sq.Eq{"tm.deleteAt": 0})
|
||||
Join(s.tablePrefix + "board_members as bm on b.id=bm.board_id").
|
||||
Where(sq.Eq{
|
||||
"b.is_template": false,
|
||||
"bm.user_id": userID,
|
||||
})
|
||||
|
||||
if includePublicBoards {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"b.type": model.BoardTypeOpen},
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
sq.Eq{"cm.userId": userID},
|
||||
teamMembersQ := builder.
|
||||
Select(boardFields("b.")...).
|
||||
From(s.tablePrefix + "boards as b").
|
||||
Join("TeamMembers as tm on tm.teamid=b.team_id").
|
||||
Where(sq.Eq{
|
||||
"b.is_template": false,
|
||||
"tm.userID": userID,
|
||||
"tm.deleteAt": 0,
|
||||
"b.type": model.BoardTypeOpen,
|
||||
})
|
||||
} else {
|
||||
query = query.Where(sq.Or{
|
||||
sq.Eq{"bm.user_id": userID},
|
||||
sq.Eq{"cm.userId": userID},
|
||||
|
||||
channelMembersQ := builder.
|
||||
Select(boardFields("b.")...).
|
||||
From(s.tablePrefix + "boards as b").
|
||||
Join("ChannelMembers as cm on cm.channelId=b.channel_id").
|
||||
Where(sq.Eq{
|
||||
"b.is_template": false,
|
||||
"cm.userId": userID,
|
||||
})
|
||||
|
||||
if term != "" {
|
||||
if searchField == model.BoardSearchFieldPropertyName {
|
||||
var where, whereTerm string
|
||||
switch s.dbType {
|
||||
case model.PostgresDBType:
|
||||
where = "b.properties->? is not null"
|
||||
whereTerm = term
|
||||
case model.MysqlDBType, model.SqliteDBType:
|
||||
where = "JSON_EXTRACT(b.properties, ?) IS NOT NULL"
|
||||
whereTerm = "$." + term
|
||||
default:
|
||||
where = "b.properties LIKE ?"
|
||||
whereTerm = "%\"" + term + "\"%"
|
||||
}
|
||||
boardMembersQ = boardMembersQ.Where(where, whereTerm)
|
||||
teamMembersQ = teamMembersQ.Where(where, whereTerm)
|
||||
channelMembersQ = channelMembersQ.Where(where, whereTerm)
|
||||
} else { // model.BoardSearchFieldTitle
|
||||
// break search query into space separated words
|
||||
// and search for all words.
|
||||
// This should later be upgraded to industrial-strength
|
||||
// word tokenizer, that uses much more than space
|
||||
// to break words.
|
||||
conditions := sq.And{}
|
||||
for _, word := range strings.Split(strings.TrimSpace(term), " ") {
|
||||
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
|
||||
}
|
||||
|
||||
boardMembersQ = boardMembersQ.Where(conditions)
|
||||
teamMembersQ = teamMembersQ.Where(conditions)
|
||||
channelMembersQ = channelMembersQ.Where(conditions)
|
||||
}
|
||||
}
|
||||
|
||||
teamMembersSQL, teamMembersArgs, err := teamMembersQ.ToSql()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SearchBoardsForUser error getting teamMembersSQL: %w", err)
|
||||
}
|
||||
|
||||
channelMembersSQL, channelMembersArgs, err := channelMembersQ.ToSql()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SearchBoardsForUser error getting channelMembersSQL: %w", err)
|
||||
}
|
||||
|
||||
unionQ := boardMembersQ
|
||||
user, err := s.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// NOTE: theoretically, could do e.g. `isGuest := !includePublicBoards`
|
||||
// but that introduces some tight coupling + fragility
|
||||
if user.IsGuest {
|
||||
var explicitMembers []*model.BoardMember
|
||||
explicitMembers, err = s.Store.GetMembersForUser(userID)
|
||||
if err != nil {
|
||||
s.logger.Error(`getMembersForUser ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
if !user.IsGuest {
|
||||
unionQ = unionQ.
|
||||
Prefix("(").
|
||||
Suffix(") UNION ("+channelMembersSQL+")", channelMembersArgs...)
|
||||
if includePublicBoards {
|
||||
unionQ = unionQ.Suffix(" UNION ("+teamMembersSQL+")", teamMembersArgs...)
|
||||
}
|
||||
boardIDs := []string{}
|
||||
for _, m := range explicitMembers {
|
||||
boardIDs = append(boardIDs, m.BoardID)
|
||||
}
|
||||
// Only explicit memberships for guests
|
||||
query = query.Where(sq.Eq{"b.id": boardIDs})
|
||||
} else if includePublicBoards {
|
||||
unionQ = unionQ.
|
||||
Prefix("(").
|
||||
Suffix(") UNION ("+teamMembersSQL+")", teamMembersArgs...)
|
||||
}
|
||||
|
||||
if term != "" {
|
||||
// break search query into space separated words
|
||||
// and search for all words.
|
||||
// This should later be upgraded to industrial-strength
|
||||
// word tokenizer, that uses much more than space
|
||||
// to break words.
|
||||
|
||||
conditions := sq.And{}
|
||||
|
||||
for _, word := range strings.Split(strings.TrimSpace(term), " ") {
|
||||
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
|
||||
}
|
||||
|
||||
query = query.Where(conditions)
|
||||
unionSQL, unionArgs, err := unionQ.ToSql()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SearchBoardsForUser error getting unionSQL: %w", err)
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
// if we're using postgres or sqlite, we need to replace the
|
||||
// question mark placeholder with the numbered dollar one, now
|
||||
// that the full query is built
|
||||
if s.dbType == model.PostgresDBType || s.dbType == model.SqliteDBType {
|
||||
var rErr error
|
||||
unionSQL, rErr = sq.Dollar.ReplacePlaceholders(unionSQL)
|
||||
if rErr != nil {
|
||||
return nil, fmt.Errorf("SearchBoardsForUser unable to replace unionSQL placeholders: %w", rErr)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.mmDB.Query(unionSQL, unionArgs...)
|
||||
if err != nil {
|
||||
s.logger.Error(`searchBoardsForUser ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
// de-duplicate manually since adding `distinct` to the query increased cost by 15X.
|
||||
// the result set for any user should be reasonably small as its based on their channel membership.
|
||||
return s.boardsFromRows(rows, true)
|
||||
return s.boardsFromRows(rows, false)
|
||||
}
|
||||
|
||||
// searchBoardsForUserInTeam returns all boards that match with the
|
||||
@ -748,7 +800,12 @@ func (s *MattermostAuthLayer) SearchBoardsForUser(term, userID string, includePu
|
||||
// they're open, regardless of the user membership.
|
||||
// Search is case-insensitive.
|
||||
func (s *MattermostAuthLayer) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) {
|
||||
openBoardsQ := s.getQueryBuilder().
|
||||
// as we're joining three queries, we need to avoid numbered
|
||||
// placeholders until the join is done, so we use the default
|
||||
// question mark placeholder here
|
||||
builder := s.getQueryBuilder().PlaceholderFormat(sq.Question)
|
||||
|
||||
openBoardsQ := builder.
|
||||
Select(boardFields("b.")...).
|
||||
From(s.tablePrefix + "boards as b").
|
||||
Where(sq.Eq{
|
||||
@ -757,7 +814,7 @@ func (s *MattermostAuthLayer) SearchBoardsForUserInTeam(teamID, term, userID str
|
||||
"b.type": model.BoardTypeOpen,
|
||||
})
|
||||
|
||||
memberBoardsQ := s.getQueryBuilder().
|
||||
memberBoardsQ := builder.
|
||||
Select(boardFields("b.")...).
|
||||
From(s.tablePrefix + "boards AS b").
|
||||
Join(s.tablePrefix + "board_members AS bm on b.id = bm.board_id").
|
||||
@ -767,7 +824,7 @@ func (s *MattermostAuthLayer) SearchBoardsForUserInTeam(teamID, term, userID str
|
||||
"bm.user_id": userID,
|
||||
})
|
||||
|
||||
channelMemberBoardsQ := s.getQueryBuilder().
|
||||
channelMemberBoardsQ := builder.
|
||||
Select(boardFields("b.")...).
|
||||
From(s.tablePrefix + "boards AS b").
|
||||
Join("ChannelMembers AS cm on cm.channelId = b.channel_id").
|
||||
@ -797,14 +854,12 @@ func (s *MattermostAuthLayer) SearchBoardsForUserInTeam(teamID, term, userID str
|
||||
|
||||
memberBoardsSQL, memberBoardsArgs, err := memberBoardsQ.ToSql()
|
||||
if err != nil {
|
||||
s.logger.Error(`searchBoardsForUser ERROR getting memberBoardsSQL`, mlog.Err(err))
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchBoardsForUserInTeam error getting memberBoardsSQL: %w", err)
|
||||
}
|
||||
|
||||
channelMemberBoardsSQL, channelMemberBoardsArgs, err := channelMemberBoardsQ.ToSql()
|
||||
if err != nil {
|
||||
s.logger.Error(`searchBoardsForUser ERROR getting channelMemberBoardsSQL`, mlog.Err(err))
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("SearchBoardsForUserInTeam error getting channelMemberBoardsSQL: %w", err)
|
||||
}
|
||||
|
||||
unionQ := openBoardsQ.
|
||||
@ -812,16 +867,30 @@ func (s *MattermostAuthLayer) SearchBoardsForUserInTeam(teamID, term, userID str
|
||||
Suffix(") UNION ("+memberBoardsSQL, memberBoardsArgs...).
|
||||
Suffix(") UNION ("+channelMemberBoardsSQL+")", channelMemberBoardsArgs...)
|
||||
|
||||
rows, err := unionQ.Query()
|
||||
unionSQL, unionArgs, err := unionQ.ToSql()
|
||||
if err != nil {
|
||||
s.logger.Error(`searchBoardsForUser ERROR`, mlog.Err(err))
|
||||
return nil, fmt.Errorf("SearchBoardsForUserInTeam error getting unionSQL: %w", err)
|
||||
}
|
||||
|
||||
// if we're using postgres or sqlite, we need to replace the
|
||||
// question mark placeholder with the numbered dollar one, now
|
||||
// that the full query is built
|
||||
if s.dbType == model.PostgresDBType || s.dbType == model.SqliteDBType {
|
||||
var rErr error
|
||||
unionSQL, rErr = sq.Dollar.ReplacePlaceholders(unionSQL)
|
||||
if rErr != nil {
|
||||
return nil, fmt.Errorf("SearchBoardsForUserInTeam unable to replace unionSQL placeholders: %w", rErr)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.mmDB.Query(unionSQL, unionArgs...)
|
||||
if err != nil {
|
||||
s.logger.Error(`searchBoardsForUserInTeam ERROR`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
// de-duplicate manually since adding `distinct` to the query increased cost by 15X.
|
||||
// the result set for any user should be reasonably small as its based on their channel membership.
|
||||
return s.boardsFromRows(rows, true)
|
||||
return s.boardsFromRows(rows, false)
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) boardsFromRows(rows *sql.Rows, removeDuplicates bool) ([]*model.Board, error) {
|
||||
|
@ -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()
|
||||
@ -1427,18 +1472,18 @@ func (mr *MockStoreMockRecorder) SaveMember(arg0 interface{}) *gomock.Call {
|
||||
}
|
||||
|
||||
// SearchBoardsForUser mocks base method.
|
||||
func (m *MockStore) SearchBoardsForUser(arg0, arg1 string, arg2 bool) ([]*model.Board, error) {
|
||||
func (m *MockStore) SearchBoardsForUser(arg0 string, arg1 model.BoardSearchField, arg2 string, arg3 bool) ([]*model.Board, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SearchBoardsForUser", arg0, arg1, arg2)
|
||||
ret := m.ctrl.Call(m, "SearchBoardsForUser", arg0, arg1, arg2, arg3)
|
||||
ret0, _ := ret[0].([]*model.Board)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// SearchBoardsForUser indicates an expected call of SearchBoardsForUser.
|
||||
func (mr *MockStoreMockRecorder) SearchBoardsForUser(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
func (mr *MockStoreMockRecorder) SearchBoardsForUser(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUser", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUser), arg0, arg1, arg2)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchBoardsForUser", reflect.TypeOf((*MockStore)(nil).SearchBoardsForUser), arg0, arg1, arg2, arg3)
|
||||
}
|
||||
|
||||
// SearchBoardsForUserInTeam mocks base method.
|
||||
|
@ -660,7 +660,7 @@ func (s *SQLStore) getMembersForBoard(db sq.BaseRunner, boardID string) ([]*mode
|
||||
// term that are either private and which the user is a member of, or
|
||||
// they're open, regardless of the user membership.
|
||||
// Search is case-insensitive.
|
||||
func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(boardFields("b.")...).
|
||||
Distinct().
|
||||
@ -680,19 +680,30 @@ func (s *SQLStore) searchBoardsForUser(db sq.BaseRunner, term, userID string, in
|
||||
}
|
||||
|
||||
if term != "" {
|
||||
// break search query into space separated words
|
||||
// and search for all words.
|
||||
// This should later be upgraded to industrial-strength
|
||||
// word tokenizer, that uses much more than space
|
||||
// to break words.
|
||||
|
||||
conditions := sq.And{}
|
||||
|
||||
for _, word := range strings.Split(strings.TrimSpace(term), " ") {
|
||||
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
|
||||
if searchField == model.BoardSearchFieldPropertyName {
|
||||
switch s.dbType {
|
||||
case model.PostgresDBType:
|
||||
where := "b.properties->? is not null"
|
||||
query = query.Where(where, term)
|
||||
case model.MysqlDBType, model.SqliteDBType:
|
||||
where := "JSON_EXTRACT(b.properties, ?) IS NOT NULL"
|
||||
query = query.Where(where, "$."+term)
|
||||
default:
|
||||
where := "b.properties LIKE ?"
|
||||
query = query.Where(where, "%\""+term+"\"%")
|
||||
}
|
||||
} else { // model.BoardSearchFieldTitle
|
||||
// break search query into space separated words
|
||||
// and search for all words.
|
||||
// This should later be upgraded to industrial-strength
|
||||
// word tokenizer, that uses much more than space
|
||||
// to break words.
|
||||
conditions := sq.And{}
|
||||
for _, word := range strings.Split(strings.TrimSpace(term), " ") {
|
||||
conditions = append(conditions, sq.Like{"lower(b.title)": "%" + strings.ToLower(word) + "%"})
|
||||
}
|
||||
query = query.Where(conditions)
|
||||
}
|
||||
|
||||
query = query.Where(conditions)
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
|
@ -2,6 +2,7 @@ package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
@ -10,9 +11,26 @@ import (
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
const categorySortOrderGap = 10
|
||||
|
||||
func (s *SQLStore) categoryFields() []string {
|
||||
return []string{
|
||||
"id",
|
||||
"name",
|
||||
"user_id",
|
||||
"team_id",
|
||||
"create_at",
|
||||
"update_at",
|
||||
"delete_at",
|
||||
"collapsed",
|
||||
"COALESCE(sort_order, 0)",
|
||||
"type",
|
||||
}
|
||||
}
|
||||
|
||||
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(s.categoryFields()...).
|
||||
From(s.tablePrefix + "categories").
|
||||
Where(sq.Eq{"id": id})
|
||||
|
||||
@ -36,6 +54,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 +70,7 @@ func (s *SQLStore) createCategory(db sq.BaseRunner, category model.Category) err
|
||||
"update_at",
|
||||
"delete_at",
|
||||
"collapsed",
|
||||
"sort_order",
|
||||
"type",
|
||||
).
|
||||
Values(
|
||||
@ -58,6 +82,7 @@ func (s *SQLStore) createCategory(db sq.BaseRunner, category model.Category) err
|
||||
category.UpdateAt,
|
||||
category.DeleteAt,
|
||||
category.Collapsed,
|
||||
category.SortOrder,
|
||||
category.Type,
|
||||
)
|
||||
|
||||
@ -66,6 +91,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 +164,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(s.categoryFields()...).
|
||||
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 +196,7 @@ func (s *SQLStore) categoriesFromRows(rows *sql.Rows) ([]model.Category, error)
|
||||
&category.UpdateAt,
|
||||
&category.DeleteAt,
|
||||
&category.Collapsed,
|
||||
&category.SortOrder,
|
||||
&category.Type,
|
||||
)
|
||||
|
||||
@ -159,3 +210,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
|
||||
}
|
||||
|
@ -663,7 +663,7 @@ func (s *SQLStore) RunFixCollationsAndCharsetsMigration() error {
|
||||
collation = "utf8mb4_general_ci"
|
||||
charSet = "utf8mb4"
|
||||
} else {
|
||||
collation, charSet, err = s.getCollationAndCharset()
|
||||
collation, charSet, err = s.getCollationAndCharset("Channels")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -677,8 +677,27 @@ func (s *SQLStore) RunFixCollationsAndCharsetsMigration() error {
|
||||
|
||||
merr := merror.New()
|
||||
|
||||
// alter each table; this is idempotent
|
||||
// alter each table if there is a collation or charset mismatch
|
||||
for _, name := range tableNames {
|
||||
tableCollation, tableCharSet, err := s.getCollationAndCharset(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if collation == tableCollation && charSet == tableCharSet {
|
||||
// nothing to do
|
||||
continue
|
||||
}
|
||||
|
||||
s.logger.Warn(
|
||||
"found collation/charset mismatch, fixing table",
|
||||
mlog.String("tableName", name),
|
||||
mlog.String("tableCollation", tableCollation),
|
||||
mlog.String("tableCharSet", tableCharSet),
|
||||
mlog.String("collation", collation),
|
||||
mlog.String("charSet", charSet),
|
||||
)
|
||||
|
||||
sql := fmt.Sprintf("ALTER TABLE %s CONVERT TO CHARACTER SET '%s' COLLATE '%s'", name, charSet, collation)
|
||||
result, err := s.db.Exec(sql)
|
||||
if err != nil {
|
||||
@ -731,7 +750,7 @@ func (s *SQLStore) getFocalBoardTableNames() ([]string, error) {
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getCollationAndCharset() (string, string, error) {
|
||||
func (s *SQLStore) getCollationAndCharset(tableName string) (string, string, error) {
|
||||
if s.dbType != model.MysqlDBType {
|
||||
return "", "", newErrInvalidDBType("getCollationAndCharset requires MySQL")
|
||||
}
|
||||
@ -739,7 +758,7 @@ func (s *SQLStore) getCollationAndCharset() (string, string, error) {
|
||||
query := s.getQueryBuilder(s.db).
|
||||
Select("table_collation").
|
||||
From("information_schema.tables").
|
||||
Where(sq.Eq{"table_name": "Channels"}).
|
||||
Where(sq.Eq{"table_name": tableName}).
|
||||
Where("table_schema=(SELECT DATABASE())")
|
||||
|
||||
row := query.QueryRow()
|
||||
@ -747,17 +766,19 @@ func (s *SQLStore) getCollationAndCharset() (string, string, error) {
|
||||
var collation string
|
||||
err := row.Scan(&collation)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("error fetching collation: %w", err)
|
||||
return "", "", fmt.Errorf("error fetching collation for table %s: %w", tableName, err)
|
||||
}
|
||||
|
||||
// obtains the charset from the first column that has it set
|
||||
query = s.getQueryBuilder(s.db).
|
||||
Select("CHARACTER_SET_NAME").
|
||||
From("information_schema.columns").
|
||||
Where(sq.Eq{
|
||||
"table_name": "Channels",
|
||||
"COLUMN_NAME": "Name",
|
||||
"table_name": tableName,
|
||||
}).
|
||||
Where("table_schema=(SELECT DATABASE())")
|
||||
Where("table_schema=(SELECT DATABASE())").
|
||||
Where(sq.NotEq{"CHARACTER_SET_NAME": "NULL"}).
|
||||
Limit(1)
|
||||
|
||||
row = query.QueryRow()
|
||||
|
||||
|
@ -7,9 +7,13 @@ import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"text/template"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
"github.com/mattermost/mattermost-server/v6/store/sqlstore"
|
||||
|
||||
@ -25,7 +29,7 @@ import (
|
||||
"github.com/mattermost/focalboard/server/model"
|
||||
)
|
||||
|
||||
//go:embed migrations
|
||||
//go:embed migrations/*.sql
|
||||
var Assets embed.FS
|
||||
|
||||
const (
|
||||
@ -56,14 +60,14 @@ func (s *SQLStore) getMigrationConnection() (*sql.DB, error) {
|
||||
}
|
||||
}
|
||||
|
||||
db, err := sql.Open(s.dbType, connectionString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var settings mmModel.SqlSettings
|
||||
settings.SetDefaults(false)
|
||||
if s.configFn != nil {
|
||||
settings = s.configFn().SqlSettings
|
||||
}
|
||||
*settings.DriverName = s.dbType
|
||||
|
||||
if err = db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db := sqlstore.SetupConnection("master", connectionString, &settings)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@ -159,10 +163,11 @@ func (s *SQLStore) Migrate() error {
|
||||
return nil, mErr
|
||||
}
|
||||
|
||||
tmpl, pErr := template.New("sql").Parse(string(asset))
|
||||
tmpl, pErr := template.New("sql").Funcs(s.GetTemplateHelperFuncs()).Parse(string(asset))
|
||||
if pErr != nil {
|
||||
return nil, pErr
|
||||
}
|
||||
|
||||
buffer := bytes.NewBufferString("")
|
||||
|
||||
err = tmpl.Execute(buffer, params)
|
||||
@ -170,6 +175,11 @@ func (s *SQLStore) Migrate() error {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Trace("migration template",
|
||||
mlog.String("name", name),
|
||||
mlog.String("sql", buffer.String()),
|
||||
)
|
||||
|
||||
return buffer.Bytes(), nil
|
||||
},
|
||||
}
|
||||
@ -282,3 +292,365 @@ func (s *SQLStore) ensureMigrationsAppliedUpToVersion(engine *morph.Morph, drive
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetTemplateHelperFuncs() template.FuncMap {
|
||||
funcs := template.FuncMap{
|
||||
"addColumnIfNeeded": s.genAddColumnIfNeeded,
|
||||
"dropColumnIfNeeded": s.genDropColumnIfNeeded,
|
||||
"createIndexIfNeeded": s.genCreateIndexIfNeeded,
|
||||
"renameTableIfNeeded": s.genRenameTableIfNeeded,
|
||||
"renameColumnIfNeeded": s.genRenameColumnIfNeeded,
|
||||
"doesTableExist": s.doesTableExist,
|
||||
"doesColumnExist": s.doesColumnExist,
|
||||
}
|
||||
return funcs
|
||||
}
|
||||
|
||||
func (s *SQLStore) genAddColumnIfNeeded(tableName, columnName, datatype, constraint string) (string, error) {
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
normTableName := normalizeTablename(s.schemaName, tableName)
|
||||
|
||||
switch s.dbType {
|
||||
case model.SqliteDBType:
|
||||
// Sqlite does not support any conditionals that can contain DDL commands. No idempotent migrations for Sqlite :-(
|
||||
return fmt.Sprintf("\nALTER TABLE %s ADD COLUMN %s %s %s;\n", normTableName, columnName, datatype, constraint), nil
|
||||
case model.MysqlDBType:
|
||||
vars := map[string]string{
|
||||
"schema": s.schemaName,
|
||||
"table_name": tableName,
|
||||
"norm_table_name": normTableName,
|
||||
"column_name": columnName,
|
||||
"data_type": datatype,
|
||||
"constraint": constraint,
|
||||
}
|
||||
return replaceVars(`
|
||||
SET @stmt = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
AND column_name = '[[column_name]]'
|
||||
) > 0,
|
||||
'SELECT 1;',
|
||||
'ALTER TABLE [[norm_table_name]] ADD COLUMN [[column_name]] [[data_type]] [[constraint]];'
|
||||
));
|
||||
PREPARE addColumnIfNeeded FROM @stmt;
|
||||
EXECUTE addColumnIfNeeded;
|
||||
DEALLOCATE PREPARE addColumnIfNeeded;
|
||||
`, vars), nil
|
||||
case model.PostgresDBType:
|
||||
return fmt.Sprintf("\nALTER TABLE %s ADD COLUMN IF NOT EXISTS %s %s %s;\n", normTableName, columnName, datatype, constraint), nil
|
||||
default:
|
||||
return "", ErrUnsupportedDatabaseType
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) genDropColumnIfNeeded(tableName, columnName string) (string, error) {
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
normTableName := normalizeTablename(s.schemaName, tableName)
|
||||
|
||||
switch s.dbType {
|
||||
case model.SqliteDBType:
|
||||
return fmt.Sprintf("\n-- Sqlite3 cannot drop columns for versions less than 3.35.0; drop column '%s' in table '%s' skipped\n", columnName, tableName), nil
|
||||
case model.MysqlDBType:
|
||||
vars := map[string]string{
|
||||
"schema": s.schemaName,
|
||||
"table_name": tableName,
|
||||
"norm_table_name": normTableName,
|
||||
"column_name": columnName,
|
||||
}
|
||||
return replaceVars(`
|
||||
SET @stmt = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
AND column_name = '[[column_name]]'
|
||||
) > 0,
|
||||
'ALTER TABLE [[norm_table_name]] DROP COLUMN [[column_name]];',
|
||||
'SELECT 1;'
|
||||
));
|
||||
PREPARE dropColumnIfNeeded FROM @stmt;
|
||||
EXECUTE dropColumnIfNeeded;
|
||||
DEALLOCATE PREPARE dropColumnIfNeeded;
|
||||
`, vars), nil
|
||||
case model.PostgresDBType:
|
||||
return fmt.Sprintf("\nALTER TABLE %s DROP COLUMN IF EXISTS %s;\n", normTableName, columnName), nil
|
||||
default:
|
||||
return "", ErrUnsupportedDatabaseType
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) genCreateIndexIfNeeded(tableName, columns string) (string, error) {
|
||||
indexName := getIndexName(tableName, columns)
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
normTableName := normalizeTablename(s.schemaName, tableName)
|
||||
|
||||
switch s.dbType {
|
||||
case model.SqliteDBType:
|
||||
// No support for idempotent index creation in Sqlite.
|
||||
return fmt.Sprintf("\nCREATE INDEX %s ON %s (%s);\n", indexName, normTableName, columns), nil
|
||||
case model.MysqlDBType:
|
||||
vars := map[string]string{
|
||||
"schema": s.schemaName,
|
||||
"table_name": tableName,
|
||||
"norm_table_name": normTableName,
|
||||
"index_name": indexName,
|
||||
"columns": columns,
|
||||
}
|
||||
return replaceVars(`
|
||||
SET @stmt = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(index_name) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
AND index_name = '[[index_name]]'
|
||||
) > 0,
|
||||
'SELECT 1;',
|
||||
'CREATE INDEX [[index_name]] ON [[norm_table_name]] ([[columns]]);'
|
||||
));
|
||||
PREPARE createIndexIfNeeded FROM @stmt;
|
||||
EXECUTE createIndexIfNeeded;
|
||||
DEALLOCATE PREPARE createIndexIfNeeded;
|
||||
`, vars), nil
|
||||
case model.PostgresDBType:
|
||||
return fmt.Sprintf("\nCREATE INDEX IF NOT EXISTS %s ON %s (%s);\n", indexName, normTableName, columns), nil
|
||||
default:
|
||||
return "", ErrUnsupportedDatabaseType
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) genRenameTableIfNeeded(oldTableName, newTableName string) (string, error) {
|
||||
oldTableName = addPrefixIfNeeded(oldTableName, s.tablePrefix)
|
||||
newTableName = addPrefixIfNeeded(newTableName, s.tablePrefix)
|
||||
|
||||
normOldTableName := normalizeTablename(s.schemaName, oldTableName)
|
||||
|
||||
vars := map[string]string{
|
||||
"schema": s.schemaName,
|
||||
"table_name": newTableName,
|
||||
"norm_old_table_name": normOldTableName,
|
||||
"new_table_name": newTableName,
|
||||
}
|
||||
|
||||
switch s.dbType {
|
||||
case model.SqliteDBType:
|
||||
// No support for idempotent table renaming in Sqlite.
|
||||
return fmt.Sprintf("\nALTER TABLE %s RENAME TO %s;\n", normOldTableName, newTableName), nil
|
||||
case model.MysqlDBType:
|
||||
return replaceVars(`
|
||||
SET @stmt = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
) > 0,
|
||||
'SELECT 1;',
|
||||
'RENAME TABLE [[norm_old_table_name]] TO [[new_table_name]];'
|
||||
));
|
||||
PREPARE renameTableIfNeeded FROM @stmt;
|
||||
EXECUTE renameTableIfNeeded;
|
||||
DEALLOCATE PREPARE renameTableIfNeeded;
|
||||
`, vars), nil
|
||||
case model.PostgresDBType:
|
||||
return replaceVars(`
|
||||
do $$
|
||||
begin
|
||||
if (SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES
|
||||
WHERE table_name = '[[new_table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
) = 0 then
|
||||
ALTER TABLE [[norm_old_table_name]] RENAME TO [[new_table_name]];
|
||||
end if;
|
||||
end$$;
|
||||
`, vars), nil
|
||||
default:
|
||||
return "", ErrUnsupportedDatabaseType
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) genRenameColumnIfNeeded(tableName, oldColumnName, newColumnName, dataType string) (string, error) {
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
normTableName := normalizeTablename(s.schemaName, tableName)
|
||||
|
||||
vars := map[string]string{
|
||||
"schema": s.schemaName,
|
||||
"table_name": tableName,
|
||||
"norm_table_name": normTableName,
|
||||
"old_column_name": oldColumnName,
|
||||
"new_column_name": newColumnName,
|
||||
"data_type": dataType,
|
||||
}
|
||||
|
||||
switch s.dbType {
|
||||
case model.SqliteDBType:
|
||||
// No support for idempotent column renaming in Sqlite.
|
||||
return fmt.Sprintf("\nALTER TABLE %s RENAME COLUMN %s TO %s;\n", normTableName, oldColumnName, newColumnName), nil
|
||||
case model.MysqlDBType:
|
||||
return replaceVars(`
|
||||
SET @stmt = (SELECT IF(
|
||||
(
|
||||
SELECT COUNT(column_name) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
AND column_name = '[[new_column_name]]'
|
||||
) > 0,
|
||||
'SELECT 1;',
|
||||
'ALTER TABLE [[norm_table_name]] CHANGE [[old_column_name]] [[new_column_name]] [[data_type]];'
|
||||
));
|
||||
PREPARE renameColumnIfNeeded FROM @stmt;
|
||||
EXECUTE renameColumnIfNeeded;
|
||||
DEALLOCATE PREPARE renameColumnIfNeeded;
|
||||
`, vars), nil
|
||||
case model.PostgresDBType:
|
||||
return replaceVars(`
|
||||
do $$
|
||||
begin
|
||||
if (SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = '[[table_name]]'
|
||||
AND table_schema = '[[schema]]'
|
||||
AND column_name = '[[new_column_name]]'
|
||||
) = 0 then
|
||||
ALTER TABLE [[norm_table_name]] RENAME COLUMN [[old_column_name]] TO [[new_column_name]];
|
||||
end if;
|
||||
end$$;
|
||||
`, vars), nil
|
||||
default:
|
||||
return "", ErrUnsupportedDatabaseType
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLStore) doesTableExist(tableName string) (bool, error) {
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
var query sq.SelectBuilder
|
||||
|
||||
switch s.dbType {
|
||||
case model.MysqlDBType, model.PostgresDBType:
|
||||
query = s.getQueryBuilder(s.db).
|
||||
Select("table_name").
|
||||
From("INFORMATION_SCHEMA.TABLES").
|
||||
Where(sq.Eq{
|
||||
"table_name": tableName,
|
||||
"table_schema": s.schemaName,
|
||||
})
|
||||
case model.SqliteDBType:
|
||||
query = s.getQueryBuilder(s.db).
|
||||
Select("name").
|
||||
From("sqlite_master").
|
||||
Where(sq.Eq{
|
||||
"name": tableName,
|
||||
"type": "table",
|
||||
})
|
||||
default:
|
||||
return false, ErrUnsupportedDatabaseType
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`doesTableExist ERROR`, mlog.Err(err))
|
||||
return false, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
exists := rows.Next()
|
||||
sql, _, _ := query.ToSql()
|
||||
|
||||
s.logger.Trace("doesTableExist",
|
||||
mlog.String("table", tableName),
|
||||
mlog.Bool("exists", exists),
|
||||
mlog.String("sql", sql),
|
||||
)
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) doesColumnExist(tableName, columnName string) (bool, error) {
|
||||
tableName = addPrefixIfNeeded(tableName, s.tablePrefix)
|
||||
var query sq.SelectBuilder
|
||||
|
||||
switch s.dbType {
|
||||
case model.MysqlDBType, model.PostgresDBType:
|
||||
query = s.getQueryBuilder(s.db).
|
||||
Select("table_name").
|
||||
From("INFORMATION_SCHEMA.COLUMNS").
|
||||
Where(sq.Eq{
|
||||
"table_name": tableName,
|
||||
"table_schema": s.schemaName,
|
||||
"column_name": columnName,
|
||||
})
|
||||
case model.SqliteDBType:
|
||||
query = s.getQueryBuilder(s.db).
|
||||
Select("name").
|
||||
From(fmt.Sprintf("pragma_table_info('%s')", tableName)).
|
||||
Where(sq.Eq{
|
||||
"name": columnName,
|
||||
})
|
||||
default:
|
||||
return false, ErrUnsupportedDatabaseType
|
||||
}
|
||||
|
||||
rows, err := query.Query()
|
||||
if err != nil {
|
||||
s.logger.Error(`doesColumnExist ERROR`, mlog.Err(err))
|
||||
return false, err
|
||||
}
|
||||
defer s.CloseRows(rows)
|
||||
|
||||
exists := rows.Next()
|
||||
sql, _, _ := query.ToSql()
|
||||
|
||||
s.logger.Trace("doesColumnExist",
|
||||
mlog.String("table", tableName),
|
||||
mlog.String("column", columnName),
|
||||
mlog.Bool("exists", exists),
|
||||
mlog.String("sql", sql),
|
||||
)
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func addPrefixIfNeeded(s, prefix string) string {
|
||||
if !strings.HasPrefix(s, prefix) {
|
||||
return prefix + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func normalizeTablename(schemaName, tableName string) string {
|
||||
if schemaName != "" && !strings.HasPrefix(tableName, schemaName+".") {
|
||||
tableName = schemaName + "." + tableName
|
||||
}
|
||||
return tableName
|
||||
}
|
||||
|
||||
func getIndexName(tableName string, columns string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
_, _ = sb.WriteString("idx_")
|
||||
_, _ = sb.WriteString(tableName)
|
||||
|
||||
// allow developers to separate column names with spaces and/or commas
|
||||
columns = strings.ReplaceAll(columns, ",", " ")
|
||||
cols := strings.Split(columns, " ")
|
||||
|
||||
for _, s := range cols {
|
||||
sub := strings.TrimSpace(s)
|
||||
if sub == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = sb.WriteString("_")
|
||||
_, _ = sb.WriteString(s)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// replaceVars replaces instances of variable placeholders with the
|
||||
// values provided via a map. Variable placeholders are of the form
|
||||
// `[[var_name]]`.
|
||||
func replaceVars(s string, vars map[string]string) string {
|
||||
for key, val := range vars {
|
||||
placeholder := "[[" + key + "]]"
|
||||
val = strings.ReplaceAll(val, "'", "\\'")
|
||||
s = strings.ReplaceAll(s, placeholder, val)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
@ -1,2 +1,2 @@
|
||||
ALTER TABLE {{.prefix}}blocks
|
||||
ADD COLUMN root_id VARCHAR(36);
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "blocks" "root_id" "varchar(36)" ""}}
|
@ -1,2 +1,2 @@
|
||||
ALTER TABLE {{.prefix}}blocks
|
||||
ADD COLUMN modified_by VARCHAR(36);
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "blocks" "modified_by" "varchar(36)" ""}}
|
@ -1,10 +1,8 @@
|
||||
ALTER TABLE {{.prefix}}blocks
|
||||
ADD COLUMN workspace_id VARCHAR(36);
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "blocks" "workspace_id" "varchar(36)" ""}}
|
||||
|
||||
ALTER TABLE {{.prefix}}sharing
|
||||
ADD COLUMN workspace_id VARCHAR(36);
|
||||
{{ addColumnIfNeeded "sharing" "workspace_id" "varchar(36)" ""}}
|
||||
|
||||
ALTER TABLE {{.prefix}}sessions
|
||||
ADD COLUMN auth_service VARCHAR(20);
|
||||
{{ addColumnIfNeeded "sessions" "auth_service" "varchar(20)" ""}}
|
||||
|
||||
UPDATE {{.prefix}}blocks SET workspace_id = '0' WHERE workspace_id = '' OR workspace_id IS NULL;
|
||||
|
@ -1,21 +1,32 @@
|
||||
ALTER TABLE {{.prefix}}blocks RENAME TO {{.prefix}}blocks_history;
|
||||
{{- /* Only perform this migration if the blocks_history table does not already exist */ -}}
|
||||
|
||||
{{- /* doesTableExist tableName */ -}}
|
||||
{{if doesTableExist "blocks_history" }}
|
||||
|
||||
SELECT 1;
|
||||
|
||||
{{else}}
|
||||
|
||||
{{- /* renameTableIfNeeded oldTableName newTableName */ -}}
|
||||
{{ renameTableIfNeeded "blocks" "blocks_history" }}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}blocks (
|
||||
id VARCHAR(36),
|
||||
{{if .postgres}}insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),{{end}}
|
||||
{{if .sqlite}}insert_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),{{end}}
|
||||
{{if .mysql}}insert_at DATETIME(6) NOT NULL DEFAULT NOW(6),{{end}}
|
||||
parent_id VARCHAR(36),
|
||||
{{if .mysql}}`schema`{{else}}schema{{end}} BIGINT,
|
||||
type TEXT,
|
||||
title TEXT,
|
||||
fields {{if .postgres}}JSON{{else}}TEXT{{end}},
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
root_id VARCHAR(36),
|
||||
modified_by VARCHAR(36),
|
||||
workspace_id VARCHAR(36),
|
||||
PRIMARY KEY (workspace_id,id)
|
||||
id VARCHAR(36),
|
||||
{{if .postgres}}insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),{{end}}
|
||||
{{if .sqlite}}insert_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),{{end}}
|
||||
{{if .mysql}}insert_at DATETIME(6) NOT NULL DEFAULT NOW(6),{{end}}
|
||||
parent_id VARCHAR(36),
|
||||
{{if .mysql}}`schema`{{else}}schema{{end}} BIGINT,
|
||||
type TEXT,
|
||||
title TEXT,
|
||||
fields {{if .postgres}}JSON{{else}}TEXT{{end}},
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
root_id VARCHAR(36),
|
||||
modified_by VARCHAR(36),
|
||||
workspace_id VARCHAR(36),
|
||||
PRIMARY KEY (workspace_id,id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
{{if .mysql}}
|
||||
@ -27,4 +38,8 @@ INSERT INTO {{.prefix}}blocks (SELECT * FROM {{.prefix}}blocks_history ORDER BY
|
||||
{{if .sqlite}}
|
||||
INSERT OR IGNORE INTO {{.prefix}}blocks SELECT * FROM {{.prefix}}blocks_history ORDER BY insert_at DESC;
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
||||
|
||||
DELETE FROM {{.prefix}}blocks where delete_at > 0;
|
||||
|
||||
|
@ -1,4 +1,7 @@
|
||||
ALTER TABLE {{.prefix}}blocks ADD COLUMN created_by VARCHAR(36);
|
||||
ALTER TABLE {{.prefix}}blocks_history ADD COLUMN created_by VARCHAR(36);
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint) */ -}}
|
||||
{{ addColumnIfNeeded "blocks" "created_by" "varchar(36)" ""}}
|
||||
{{ addColumnIfNeeded "blocks_history" "created_by" "varchar(36)" ""}}
|
||||
|
||||
UPDATE {{.prefix}}blocks SET created_by = COALESCE(NULLIF((select modified_by from {{.prefix}}blocks_history where {{.prefix}}blocks_history.id = {{.prefix}}blocks.id ORDER BY {{.prefix}}blocks_history.insert_at ASC limit 1), ''), 'system');
|
||||
UPDATE {{.prefix}}blocks SET created_by =
|
||||
COALESCE(NULLIF((select modified_by from {{.prefix}}blocks_history where {{.prefix}}blocks_history.id = {{.prefix}}blocks.id ORDER BY {{.prefix}}blocks_history.insert_at ASC limit 1), ''), 'system')
|
||||
WHERE created_by IS NULL;
|
||||
|
@ -1,51 +1,7 @@
|
||||
{{if and .mysql .plugin}}
|
||||
-- collation of mattermost's Channels table
|
||||
SET @mattermostCollation = (SELECT table_collation from information_schema.tables WHERE table_name = 'Channels' AND table_schema = (SELECT DATABASE()));
|
||||
{{- /* All tables have collation fixed via code at startup so this migration is no longer needed. */ -}}
|
||||
{{- /* See https://github.com/mattermost/focalboard/pull/4002 */ -}}
|
||||
|
||||
-- blocks
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}blocks COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
SELECT 1;
|
||||
|
||||
-- blocks history
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}blocks_history COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- sessions
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}sessions COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- sharing
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}sharing COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- system settings
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}system_settings COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- users
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}users COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- workspaces
|
||||
SET @updateCollationQuery = CONCAT('ALTER TABLE {{.prefix}}workspaces COLLATE ', @mattermostCollation);
|
||||
PREPARE stmt FROM @updateCollationQuery;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
{{else}}
|
||||
-- We need a query here otherwise the migration will result
|
||||
-- in an empty query when the if condition is false.
|
||||
-- Empty query causes a "Query was empty" error.
|
||||
SELECT 1;
|
||||
{{end}}
|
||||
|
@ -1,14 +1,17 @@
|
||||
{{if .mysql}}
|
||||
RENAME TABLE {{.prefix}}workspaces TO {{.prefix}}teams;
|
||||
ALTER TABLE {{.prefix}}blocks CHANGE workspace_id channel_id VARCHAR(36);
|
||||
ALTER TABLE {{.prefix}}blocks_history CHANGE workspace_id channel_id VARCHAR(36);
|
||||
{{else}}
|
||||
ALTER TABLE {{.prefix}}workspaces RENAME TO {{.prefix}}teams;
|
||||
ALTER TABLE {{.prefix}}blocks RENAME COLUMN workspace_id TO channel_id;
|
||||
ALTER TABLE {{.prefix}}blocks_history RENAME COLUMN workspace_id TO channel_id;
|
||||
{{end}}
|
||||
ALTER TABLE {{.prefix}}blocks ADD COLUMN board_id VARCHAR(36);
|
||||
ALTER TABLE {{.prefix}}blocks_history ADD COLUMN board_id VARCHAR(36);
|
||||
{{- /* renameTableIfNeeded oldTableName newTableName string */ -}}
|
||||
{{ renameTableIfNeeded "workspaces" "teams" }}
|
||||
|
||||
{{- /* renameColumnIfNeeded tableName oldColumnName newColumnName dataType */ -}}
|
||||
{{ renameColumnIfNeeded "blocks" "workspace_id" "channel_id" "varchar(36)" }}
|
||||
{{ renameColumnIfNeeded "blocks_history" "workspace_id" "channel_id" "varchar(36)" }}
|
||||
|
||||
{{- /* dropColumnIfNeeded tableName columnName */ -}}
|
||||
{{ dropColumnIfNeeded "blocks" "workspace_id" }}
|
||||
{{ dropColumnIfNeeded "blocks_history" "workspace_id" }}
|
||||
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "blocks" "board_id" "varchar(36)" ""}}
|
||||
{{ addColumnIfNeeded "blocks_history" "board_id" "varchar(36)" ""}}
|
||||
|
||||
{{- /* cleanup incorrect data format in column calculations */ -}}
|
||||
{{- /* then move from 'board' type to 'view' type*/ -}}
|
||||
@ -24,6 +27,7 @@ UPDATE {{.prefix}}blocks b
|
||||
WHERE JSON_EXTRACT(b.fields, '$.viewType') = 'table'
|
||||
AND b.type = 'view';
|
||||
{{end}}
|
||||
|
||||
{{if .postgres}}
|
||||
UPDATE {{.prefix}}blocks SET fields = fields::jsonb - 'columnCalculations' || '{"columnCalculations": {}}' WHERE fields->>'columnCalculations' = '[]';
|
||||
|
||||
@ -37,6 +41,7 @@ UPDATE {{.prefix}}blocks b
|
||||
AND b.fields ->> 'viewType' = 'table'
|
||||
AND b.type = 'view';
|
||||
{{end}}
|
||||
|
||||
{{if .sqlite}}
|
||||
UPDATE {{.prefix}}blocks SET fields = replace(fields, '"columnCalculations":[]', '"columnCalculations":{}');
|
||||
|
||||
@ -49,7 +54,8 @@ UPDATE {{.prefix}}blocks AS b
|
||||
AND b.type = 'view';
|
||||
{{end}}
|
||||
|
||||
/* TODO: Migrate the columnCalculations at app level and remove it from the boards and boards_history tables */
|
||||
{{- /* TODO: Migrate the columnCalculations at app level and remove it from the boards and boards_history tables */ -}}
|
||||
|
||||
|
||||
{{- /* add boards tables */ -}}
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}boards (
|
||||
@ -87,8 +93,9 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards (
|
||||
delete_at BIGINT
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
CREATE INDEX idx_board_team_id ON {{.prefix}}boards(team_id, is_template);
|
||||
CREATE INDEX idx_board_channel_id ON {{.prefix}}boards(channel_id);
|
||||
{{- /* createIndexIfNeeded tableName columns */ -}}
|
||||
{{ createIndexIfNeeded "boards" "team_id, is_template" }}
|
||||
{{ createIndexIfNeeded "boards" "channel_id" }}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
@ -140,7 +147,7 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
COALESCE((fields->'isTemplate')::text::boolean, false),
|
||||
COALESCE((B.fields->'templateVer')::text::int, 0),
|
||||
'{}', B.fields->'cardProperties', B.create_at,
|
||||
B.update_at, B.delete_at
|
||||
B.update_at, B.delete_at {{if doesColumnExist "boards" "minimum_role"}} ,'' {{end}}
|
||||
FROM {{.prefix}}blocks AS B
|
||||
INNER JOIN channels AS C ON C.Id=B.channel_id
|
||||
WHERE B.type='board'
|
||||
@ -154,7 +161,7 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
COALESCE((fields->'isTemplate')::text::boolean, false),
|
||||
COALESCE((B.fields->'templateVer')::text::int, 0),
|
||||
'{}', B.fields->'cardProperties', B.create_at,
|
||||
B.update_at, B.delete_at
|
||||
B.update_at, B.delete_at {{if doesColumnExist "boards_history" "minimum_role"}} ,'' {{end}}
|
||||
FROM {{.prefix}}blocks_history AS B
|
||||
INNER JOIN channels AS C ON C.Id=B.channel_id
|
||||
WHERE B.type='board'
|
||||
@ -170,7 +177,7 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
COALESCE(JSON_EXTRACT(B.fields, '$.isTemplate'), 'false') = 'true',
|
||||
COALESCE(JSON_EXTRACT(B.fields, '$.templateVer'), 0),
|
||||
'{}', JSON_EXTRACT(B.fields, '$.cardProperties'), B.create_at,
|
||||
B.update_at, B.delete_at
|
||||
B.update_at, B.delete_at {{if doesColumnExist "boards" "minimum_role"}} ,'' {{end}}
|
||||
FROM {{.prefix}}blocks AS B
|
||||
INNER JOIN Channels AS C ON C.Id=B.channel_id
|
||||
WHERE B.type='board'
|
||||
@ -184,7 +191,7 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
COALESCE(JSON_EXTRACT(B.fields, '$.isTemplate'), 'false') = 'true',
|
||||
COALESCE(JSON_EXTRACT(B.fields, '$.templateVer'), 0),
|
||||
'{}', JSON_EXTRACT(B.fields, '$.cardProperties'), B.create_at,
|
||||
B.update_at, B.delete_at
|
||||
B.update_at, B.delete_at {{if doesColumnExist "boards_history" "minimum_role"}} ,'' {{end}}
|
||||
FROM {{.prefix}}blocks_history AS B
|
||||
INNER JOIN Channels AS C ON C.Id=B.channel_id
|
||||
WHERE B.type='board'
|
||||
@ -201,7 +208,7 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
COALESCE((fields->'isTemplate')::text::boolean, false),
|
||||
COALESCE((B.fields->'templateVer')::text::int, 0),
|
||||
'{}', fields->'cardProperties', create_at,
|
||||
update_at, delete_at
|
||||
update_at, delete_at {{if doesColumnExist "boards" "minimum_role"}} ,'editor' {{end}}
|
||||
FROM {{.prefix}}blocks AS B
|
||||
WHERE type='board'
|
||||
);
|
||||
@ -214,11 +221,12 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
COALESCE((fields->'isTemplate')::text::boolean, false),
|
||||
COALESCE((B.fields->'templateVer')::text::int, 0),
|
||||
'{}', fields->'cardProperties', create_at,
|
||||
update_at, delete_at
|
||||
update_at, delete_at {{if doesColumnExist "boards_history" "minimum_role"}} ,'editor' {{end}}
|
||||
FROM {{.prefix}}blocks_history AS B
|
||||
WHERE type='board'
|
||||
);
|
||||
{{end}}
|
||||
|
||||
{{if .mysql}}
|
||||
INSERT INTO {{.prefix}}boards (
|
||||
SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O',
|
||||
@ -229,7 +237,7 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
COALESCE(JSON_EXTRACT(B.fields, '$.isTemplate'), 'false') = 'true',
|
||||
COALESCE(JSON_EXTRACT(B.fields, '$.templateVer'), 0),
|
||||
'{}', JSON_EXTRACT(fields, '$.cardProperties'), create_at,
|
||||
update_at, delete_at
|
||||
update_at, delete_at {{if doesColumnExist "boards" "minimum_role"}} ,'editor' {{end}}
|
||||
FROM {{.prefix}}blocks AS B
|
||||
WHERE type='board'
|
||||
);
|
||||
@ -242,11 +250,12 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
COALESCE(JSON_EXTRACT(B.fields, '$.isTemplate'), 'false') = 'true',
|
||||
COALESCE(JSON_EXTRACT(B.fields, '$.templateVer'), 0),
|
||||
'{}', JSON_EXTRACT(fields, '$.cardProperties'), create_at,
|
||||
update_at, delete_at
|
||||
update_at, delete_at {{if doesColumnExist "boards_history" "minimum_role"}} ,'editor' {{end}}
|
||||
FROM {{.prefix}}blocks_history AS B
|
||||
WHERE type='board'
|
||||
);
|
||||
{{end}}
|
||||
|
||||
{{if .sqlite}}
|
||||
INSERT INTO {{.prefix}}boards
|
||||
SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O',
|
||||
@ -255,7 +264,7 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
json_extract(fields, '$.icon'), json_extract(fields, '$.showDescription'), json_extract(fields, '$.isTemplate'),
|
||||
COALESCE(json_extract(fields, '$.templateVer'), 0),
|
||||
'{}', json_extract(fields, '$.cardProperties'), create_at,
|
||||
update_at, delete_at
|
||||
update_at, delete_at {{if doesColumnExist "boards" "minimum_role"}} ,'editor' {{end}}
|
||||
FROM {{.prefix}}blocks
|
||||
WHERE type='board'
|
||||
;
|
||||
@ -266,7 +275,7 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
json_extract(fields, '$.icon'), json_extract(fields, '$.showDescription'), json_extract(fields, '$.isTemplate'),
|
||||
COALESCE(json_extract(fields, '$.templateVer'), 0),
|
||||
'{}', json_extract(fields, '$.cardProperties'), create_at,
|
||||
update_at, delete_at
|
||||
update_at, delete_at {{if doesColumnExist "boards_history" "minimum_role"}} ,'editor' {{end}}
|
||||
FROM {{.prefix}}blocks_history
|
||||
WHERE type='board'
|
||||
;
|
||||
@ -275,14 +284,15 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}boards_history (
|
||||
|
||||
|
||||
{{- /* Update block references to boards*/ -}}
|
||||
UPDATE {{.prefix}}blocks SET board_id=root_id;
|
||||
UPDATE {{.prefix}}blocks_history SET board_id=root_id;
|
||||
UPDATE {{.prefix}}blocks SET board_id=root_id WHERE board_id IS NULL OR board_id='';
|
||||
UPDATE {{.prefix}}blocks_history SET board_id=root_id WHERE board_id IS NULL OR board_id='';
|
||||
|
||||
{{- /* Remove boards, including templates */ -}}
|
||||
DELETE FROM {{.prefix}}blocks WHERE type = 'board';
|
||||
DELETE FROM {{.prefix}}blocks_history WHERE type = 'board';
|
||||
|
||||
{{- /* add board_members */ -}}
|
||||
{{- /* add board_members (only if boards_members doesn't already exist) */ -}}
|
||||
{{if not (doesTableExist "board_members") }}
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}board_members (
|
||||
board_id VARCHAR(36) NOT NULL,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
@ -294,8 +304,6 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}board_members (
|
||||
PRIMARY KEY (board_id, user_id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
CREATE INDEX idx_boardmembers_user_id ON {{.prefix}}board_members(user_id);
|
||||
|
||||
{{- /* if we're in plugin, migrate channel memberships to the board */ -}}
|
||||
{{if .plugin}}
|
||||
INSERT INTO {{.prefix}}board_members (
|
||||
@ -321,3 +329,7 @@ INSERT INTO {{.prefix}}board_members
|
||||
SELECT B.id, 'single-user', '', TRUE, TRUE, FALSE, FALSE
|
||||
FROM {{.prefix}}boards AS B;
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{- /* createIndexIfNeeded tableName columns */ -}}
|
||||
{{ createIndexIfNeeded "board_members" "user_id" }}
|
||||
|
@ -10,4 +10,6 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}categories (
|
||||
PRIMARY KEY (id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
CREATE INDEX idx_categories_user_id_team_id ON {{.prefix}}categories(user_id, team_id);
|
||||
{{- /* createIndexIfNeeded tableName columns */ -}}
|
||||
{{ createIndexIfNeeded "categories" "user_id, team_id" }}
|
||||
|
||||
|
@ -9,4 +9,5 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}category_boards (
|
||||
PRIMARY KEY (id)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
CREATE INDEX idx_categoryboards_category_id ON {{.prefix}}category_boards(category_id);
|
||||
{{- /* createIndexIfNeeded tableName columns */ -}}
|
||||
{{ createIndexIfNeeded "category_boards" "category_id" }}
|
||||
|
@ -1,3 +1,10 @@
|
||||
{{- /* Only perform this migration if the board_members_history table does not already exist */ -}}
|
||||
{{if doesTableExist "board_members_history" }}
|
||||
|
||||
SELECT 1;
|
||||
|
||||
{{else}}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}board_members_history (
|
||||
board_id VARCHAR(36) NOT NULL,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
@ -8,7 +15,10 @@ CREATE TABLE IF NOT EXISTS {{.prefix}}board_members_history (
|
||||
PRIMARY KEY (board_id, user_id, insert_at)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
CREATE INDEX idx_boardmembershistory_user_id ON {{.prefix}}board_members_history(user_id);
|
||||
CREATE INDEX idx_boardmembershistory_board_id_user_id ON {{.prefix}}board_members_history(board_id, user_id);
|
||||
|
||||
INSERT INTO {{.prefix}}board_members_history (board_id, user_id, action) SELECT board_id, user_id, 'created' from {{.prefix}}board_members;
|
||||
|
||||
{{end}}
|
||||
|
||||
{{- /* createIndexIfNeeded tableName columns */ -}}
|
||||
{{ createIndexIfNeeded "board_members_history" "user_id" }}
|
||||
{{ createIndexIfNeeded "board_members_history" "board_id, user_id" }}
|
||||
|
@ -1,4 +1,6 @@
|
||||
ALTER TABLE {{.prefix}}boards ADD COLUMN minimum_role VARCHAR(36) NOT NULL DEFAULT '';
|
||||
ALTER TABLE {{.prefix}}boards_history ADD COLUMN minimum_role VARCHAR(36) NOT NULL DEFAULT '';
|
||||
UPDATE {{.prefix}}boards SET minimum_role = 'editor';
|
||||
UPDATE {{.prefix}}boards_history SET minimum_role = 'editor';
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "boards" "minimum_role" "varchar(36)" "NOT NULL DEFAULT ''"}}
|
||||
{{ addColumnIfNeeded "boards_history" "minimum_role" "varchar(36)" "NOT NULL DEFAULT ''"}}
|
||||
|
||||
UPDATE {{.prefix}}boards SET minimum_role = 'editor' WHERE minimum_role IS NULL OR minimum_role='';
|
||||
UPDATE {{.prefix}}boards_history SET minimum_role = 'editor' WHERE minimum_role IS NULL OR minimum_role='';
|
||||
|
@ -1 +1,2 @@
|
||||
ALTER TABLE {{.prefix}}categories ADD collapsed boolean default false;
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "categories" "collapsed" "boolean" "default false"}}
|
||||
|
@ -13,7 +13,7 @@ ALTER TABLE {{.prefix}}blocks ADD PRIMARY KEY (id);
|
||||
{{if .sqlite}}
|
||||
ALTER TABLE {{.prefix}}blocks RENAME TO {{.prefix}}blocks_tmp;
|
||||
|
||||
CREATE TABLE {{.prefix}}blocks (
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}blocks (
|
||||
id VARCHAR(36),
|
||||
insert_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
|
||||
parent_id VARCHAR(36),
|
||||
@ -38,7 +38,7 @@ DROP TABLE {{.prefix}}blocks_tmp;
|
||||
{{end}}
|
||||
|
||||
{{- /* most block searches use board_id or a combination of board and parent ids */ -}}
|
||||
CREATE INDEX idx_blocks_board_id_parent_id ON {{.prefix}}blocks (board_id, parent_id);
|
||||
{{ createIndexIfNeeded "blocks" "board_id, parent_id" }}
|
||||
|
||||
{{- /* get subscriptions is used once per board page load */ -}}
|
||||
CREATE INDEX idx_subscriptions_subscriber_id ON {{.prefix}}subscriptions (subscriber_id);
|
||||
{{ createIndexIfNeeded "subscriptions" "subscriber_id" }}
|
@ -1,14 +1,12 @@
|
||||
create table {{.prefix}}preferences
|
||||
CREATE TABLE IF NOT EXISTS {{.prefix}}preferences
|
||||
(
|
||||
userid varchar(36) not null,
|
||||
category varchar(32) not null,
|
||||
name varchar(32) not null,
|
||||
value text null,
|
||||
primary key (userid, category, name)
|
||||
);
|
||||
userid VARCHAR(36) NOT NULL,
|
||||
category VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(32) NOT NULL,
|
||||
value TEXT NULL,
|
||||
PRIMARY KEY (userid, category, name)
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
||||
|
||||
create index idx_{{.prefix}}preferences_category
|
||||
on {{.prefix}}preferences (category);
|
||||
|
||||
create index idx_{{.prefix}}preferences_name
|
||||
on {{.prefix}}preferences (name);
|
||||
{{- /* createIndexIfNeeded tableName columns */ -}}
|
||||
{{ createIndexIfNeeded "preferences" "category" }}
|
||||
{{ createIndexIfNeeded "preferences" "name" }}
|
||||
|
@ -48,7 +48,7 @@
|
||||
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'version72MessageCanceled', replace((Props->'focalboard_version72MessageCanceled')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_version72MessageCanceled' IS NOT NULL ON CONFLICT DO NOTHING;
|
||||
INSERT INTO {{.prefix}}preferences (UserId, Category, Name, Value) SELECT Id, 'focalboard', 'lastWelcomeVersion', replace((Props->'focalboard_lastWelcomeVersion')::varchar, '"', '') from {{.prefix}}users WHERE Props->'focalboard_lastWelcomeVersion' IS NOT NULL ON CONFLICT DO NOTHING;
|
||||
|
||||
UPDATE {{.prefix}}users SET props = (props::jsonb - 'focalboard_welcomePageViewed' - 'hiddenBoardIDs' - 'focalboard_tourCategory' - 'focalboard_onboardingTourStep' - 'focalboard_onboardingTourStarted' - 'focalboard_version72MessageCanceled' - 'focalboard_lastWelcomeVersion')::json WHERE jsonb_typeof(props::jsonb) = 'object';
|
||||
UPDATE {{.prefix}}users SET props = (props::jsonb - 'focalboard_welcomePageViewed' - 'hiddenBoardIDs' - 'focalboard_tourCategory' - 'focalboard_onboardingTourStep' - 'focalboard_onboardingTourStarted' - 'focalboard_version72MessageCanceled' - 'focalboard_lastWelcomeVersion')::json WHERE jsonb_typeof(props::jsonb) = 'object';
|
||||
{{end}}
|
||||
|
||||
{{if .mysql}}
|
||||
|
@ -1,2 +1,4 @@
|
||||
ALTER TABLE {{.prefix}}categories ADD COLUMN type varchar(64);
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "categories" "type" "varchar(64)" ""}}
|
||||
|
||||
UPDATE {{.prefix}}categories SET type = 'custom' WHERE type IS NULL;
|
||||
|
@ -0,0 +1 @@
|
||||
SELECT 1;
|
@ -0,0 +1,2 @@
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "categories" "sort_order" "BIGINT" ""}}
|
@ -0,0 +1 @@
|
||||
SELECT 1;
|
@ -0,0 +1,2 @@
|
||||
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
|
||||
{{ addColumnIfNeeded "category_boards" "sort_order" "BIGINT" ""}}
|
@ -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 @focalboard_numCategories = (SELECT COUNT(*) FROM {{.prefix}}categories);
|
||||
UPDATE {{.prefix}}categories SET sort_order = (10 * @focalboard_numCategories) WHERE lower(name) = 'boards';
|
||||
SET @focalboard_numCategories = NULL;
|
||||
{{end}}
|
68
server/services/store/sqlstore/migrations/README.md
Normal file
68
server/services/store/sqlstore/migrations/README.md
Normal file
@ -0,0 +1,68 @@
|
||||
# Migration Scripts
|
||||
|
||||
These scripts are executed against the current database on server start-up. Any scripts previously executed are skipped, however these scripts are designed to be idempotent for Postgres and MySQL. To correct common problems with schema and data migrations the `focalboard_schema_migrations` table can be cleared of all records and the server restarted.
|
||||
|
||||
The following built-in variables are available:
|
||||
|
||||
| Name | Syntax | Description |
|
||||
| ----- | ----- | ----- |
|
||||
| schemaName | {{ .schemaName }} | Returns the database/schema name (e.g. `mattermost_`, `mattermost_test`, `public`, ...) |
|
||||
| prefix | {{ .prefix }} | Returns the table name prefix (e.g. `focalbaord_`) |
|
||||
| postgres | {{if .postgres }} ... {{end}} | Returns true if the current database is Postgres. |
|
||||
| sqlite | {{if .sqlite }} ... {{end}} | Returns true if the current database is Sqlite3. |
|
||||
| mysql | {{if .mysql }} ... {{end}} | Returns true if the current database is MySQL. |
|
||||
| plugin | {{if .plugin }} ... {{end}} | Returns true if the server is currently running as a plugin (or product). In others words this is true if the server is not running as stand-alone or personal server. |
|
||||
| singleUser | {{if .singleUser }} ... {{end}} | Returns true if the server is currently running in single user mode. |
|
||||
|
||||
To help with creating scripts that are idempotent some template functions have been added to the migration engine.
|
||||
|
||||
| Name | Syntax | Description |
|
||||
| ----- | ----- | ----- |
|
||||
| addColumnIfNeeded | {{ addColumnIfNeeded schemaName tableName columnName datatype constraint }} | Adds column to table only if column doesn't already exist. |
|
||||
| dropColumnIfNeeded | {{ dropColumnIfNeeded schemaName tableName columnName }} | Drops column from table if the column exists. |
|
||||
| createIndexIfNeeded | {{ createIndexIfNeeded schemaName tableName columns }} | Creates an index if it does not already exist. The index name follows the existing convention of using `idx_` plus the table name and all columns separated by underscores. |
|
||||
| renameTableIfNeeded | {{ renameTableIfNeeded schemaName oldTableName newTableName }} | Renames the table if the new table name does not exist. |
|
||||
| renameColumnIfNeeded | {{ renameColumnIfNeeded schemaName tableName oldVolumnName newColumnName datatype }} | Renames a column if the new column name does not exist. |
|
||||
| doesTableExist | {{if doesTableExist schemaName tableName }} ... {{end}} | Returns true if the table exists. Typically used in a `if` statement to conditionally include a section of script. Currently the existence of the table is determined before any scripts are executed (limitation of Morph). |
|
||||
| doesColumnExist | {{if doesTableExist schemaName tableName columnName }} ... {{end}} | Returns true if the column exists. Typically used in a `if` statement to conditionally include a section of script. Currently the existence of the column is determined before any scripts are executed (limitation of Morph). |
|
||||
|
||||
**Note, table names should not include table prefix or schema name.**
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
{{ addColumnIfNeeded .schemaName "categories" "type" "varchar(64)" ""}}
|
||||
{{ addColumnIfNeeded .schemaName "boards_history" "minimum_role" "varchar(36)" "NOT NULL DEFAULT ''"}}
|
||||
```
|
||||
|
||||
```bash
|
||||
{{ dropColumnIfNeeded .schemaName "blocks_history" "workspace_id" }}
|
||||
```
|
||||
|
||||
```bash
|
||||
{{ createIndexIfNeeded .schemaName "boards" "team_id, is_template" }}
|
||||
```
|
||||
|
||||
```bash
|
||||
{{ renameTableIfNeeded .schemaName "blocks" "blocks_history" }}
|
||||
```
|
||||
|
||||
```bash
|
||||
{{ renameColumnIfNeeded .schemaName "blocks_history" "workspace_id" "channel_id" "varchar(36)" }}
|
||||
```
|
||||
|
||||
```bash
|
||||
{{if doesTableExist .schemaName "blocks_history" }}
|
||||
SELECT 'table exists';
|
||||
{{end}}
|
||||
|
||||
{{if not (doesTableExist .schemaName "blocks_history") }}
|
||||
SELECT 1;
|
||||
{{end}}
|
||||
```
|
||||
|
||||
```bash
|
||||
{{if doesColumnExist .schemaName "boards_history" "minimum_role"}}
|
||||
UPDATE ...
|
||||
{{end}}
|
||||
```
|
@ -139,7 +139,7 @@ func (bm *BoardsMigrator) getMorphConnection() (*morph.Morph, drivers.Driver, er
|
||||
return nil, mErr
|
||||
}
|
||||
|
||||
tmpl, pErr := template.New("sql").Parse(string(asset))
|
||||
tmpl, pErr := template.New("sql").Funcs(bm.store.GetTemplateHelperFuncs()).Parse(string(asset))
|
||||
if pErr != nil {
|
||||
return nil, pErr
|
||||
}
|
||||
|
@ -26,6 +26,7 @@ type Params struct {
|
||||
NewMutexFn MutexFactory
|
||||
ServicesAPI servicesAPI
|
||||
SkipMigrations bool
|
||||
ConfigFn func() *mmModel.Config
|
||||
}
|
||||
|
||||
func (p Params) CheckValid() error {
|
||||
|
@ -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)
|
||||
@ -791,8 +825,8 @@ func (s *SQLStore) SaveMember(bm *model.BoardMember) (*model.BoardMember, error)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SearchBoardsForUser(term string, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return s.searchBoardsForUser(s.db, term, userID, includePublicBoards)
|
||||
func (s *SQLStore) SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error) {
|
||||
return s.searchBoardsForUser(s.db, term, searchField, userID, includePublicBoards)
|
||||
|
||||
}
|
||||
|
||||
|
@ -28,6 +28,8 @@ type SQLStore struct {
|
||||
NewMutexFn MutexFactory
|
||||
servicesAPI servicesAPI
|
||||
isBinaryParam bool
|
||||
schemaName string
|
||||
configFn func() *mmModel.Config
|
||||
}
|
||||
|
||||
// MutexFactory is used by the store in plugin mode to generate
|
||||
@ -52,6 +54,7 @@ func New(params Params) (*SQLStore, error) {
|
||||
isSingleUser: params.IsSingleUser,
|
||||
NewMutexFn: params.NewMutexFn,
|
||||
servicesAPI: params.ServicesAPI,
|
||||
configFn: params.ConfigFn,
|
||||
}
|
||||
|
||||
var err error
|
||||
@ -61,6 +64,12 @@ func New(params Params) (*SQLStore, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store.schemaName, err = store.GetSchemaName()
|
||||
if err != nil {
|
||||
params.Logger.Error(`Cannot get schema name`, mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !params.SkipMigrations {
|
||||
if mErr := store.Migrate(); mErr != nil {
|
||||
params.Logger.Error(`Table creation / migration failed`, mlog.Err(mErr))
|
||||
|
@ -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"
|
||||
)
|
||||
|
||||
|
@ -138,3 +138,28 @@ func (s *SQLStore) castInt(val int64, as string) string {
|
||||
}
|
||||
return fmt.Sprintf("cast(%d as bigint) AS %s", val, as)
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetSchemaName() (string, error) {
|
||||
var query sq.SelectBuilder
|
||||
|
||||
switch s.dbType {
|
||||
case model.MysqlDBType:
|
||||
query = s.getQueryBuilder(s.db).Select("DATABASE()")
|
||||
case model.PostgresDBType:
|
||||
query = s.getQueryBuilder(s.db).Select("current_schema()")
|
||||
case model.SqliteDBType:
|
||||
return "", nil
|
||||
default:
|
||||
return "", ErrUnsupportedDatabaseType
|
||||
}
|
||||
|
||||
scanner := query.QueryRow()
|
||||
|
||||
var result string
|
||||
err := scanner.Scan(&result)
|
||||
if err != nil && !model.IsErrNotFound(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
@ -104,7 +104,7 @@ type Store interface {
|
||||
GetMembersForBoard(boardID string) ([]*model.BoardMember, error)
|
||||
GetMembersForUser(userID string) ([]*model.BoardMember, error)
|
||||
CanSeeUser(seerID string, seenID string) (bool, error)
|
||||
SearchBoardsForUser(term, userID string, includePublicBoards bool) ([]*model.Board, error)
|
||||
SearchBoardsForUser(term string, searchField model.BoardSearchField, userID string, includePublicBoards bool) ([]*model.Board, error)
|
||||
SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error)
|
||||
|
||||
// @withTransaction
|
||||
@ -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
|
||||
|
@ -796,25 +796,27 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
userID := "user-id-1"
|
||||
|
||||
t.Run("should return empty if user is not a member of any board and there are no public boards on the team", func(t *testing.T) {
|
||||
boards, err := store.SearchBoardsForUser("", userID, true)
|
||||
boards, err := store.SearchBoardsForUser("", model.BoardSearchFieldTitle, userID, true)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, boards)
|
||||
})
|
||||
|
||||
board1 := &model.Board{
|
||||
ID: "board-id-1",
|
||||
TeamID: teamID1,
|
||||
Type: model.BoardTypeOpen,
|
||||
Title: "Public Board with admin",
|
||||
ID: "board-id-1",
|
||||
TeamID: teamID1,
|
||||
Type: model.BoardTypeOpen,
|
||||
Title: "Public Board with admin",
|
||||
Properties: map[string]any{"foo": "bar1"},
|
||||
}
|
||||
_, _, err := store.InsertBoardWithAdmin(board1, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
board2 := &model.Board{
|
||||
ID: "board-id-2",
|
||||
TeamID: teamID1,
|
||||
Type: model.BoardTypeOpen,
|
||||
Title: "Public Board",
|
||||
ID: "board-id-2",
|
||||
TeamID: teamID1,
|
||||
Type: model.BoardTypeOpen,
|
||||
Title: "Public Board",
|
||||
Properties: map[string]any{"foo": "bar2"},
|
||||
}
|
||||
_, err = store.InsertBoard(board2, userID)
|
||||
require.NoError(t, err)
|
||||
@ -851,6 +853,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID string
|
||||
UserID string
|
||||
Term string
|
||||
SearchField model.BoardSearchField
|
||||
IncludePublic bool
|
||||
ExpectedBoardIDs []string
|
||||
}{
|
||||
@ -859,6 +862,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "",
|
||||
SearchField: model.BoardSearchFieldTitle,
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID},
|
||||
},
|
||||
@ -867,6 +871,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "board",
|
||||
SearchField: model.BoardSearchFieldTitle,
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{board1.ID, board2.ID, board3.ID, board5.ID},
|
||||
},
|
||||
@ -875,6 +880,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "board",
|
||||
SearchField: model.BoardSearchFieldTitle,
|
||||
IncludePublic: false,
|
||||
ExpectedBoardIDs: []string{board1.ID, board3.ID, board5.ID},
|
||||
},
|
||||
@ -883,6 +889,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "public",
|
||||
SearchField: model.BoardSearchFieldTitle,
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{board1.ID, board2.ID, board5.ID},
|
||||
},
|
||||
@ -891,6 +898,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "priv",
|
||||
SearchField: model.BoardSearchFieldTitle,
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{board3.ID},
|
||||
},
|
||||
@ -899,6 +907,25 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
TeamID: teamID2,
|
||||
UserID: userID,
|
||||
Term: "non-matching-term",
|
||||
SearchField: model.BoardSearchFieldTitle,
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{},
|
||||
},
|
||||
{
|
||||
Name: "should find all boards with a named property",
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "foo",
|
||||
SearchField: model.BoardSearchFieldPropertyName,
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{board1.ID, board2.ID},
|
||||
},
|
||||
{
|
||||
Name: "should find no boards with a non-existing named property",
|
||||
TeamID: teamID1,
|
||||
UserID: userID,
|
||||
Term: "bogus",
|
||||
SearchField: model.BoardSearchFieldPropertyName,
|
||||
IncludePublic: true,
|
||||
ExpectedBoardIDs: []string{},
|
||||
},
|
||||
@ -906,7 +933,7 @@ func testSearchBoardsForUser(t *testing.T, store store.Store) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
boards, err := store.SearchBoardsForUser(tc.Term, tc.UserID, tc.IncludePublic)
|
||||
boards, err := store.SearchBoardsForUser(tc.Term, tc.SearchField, tc.UserID, tc.IncludePublic)
|
||||
require.NoError(t, err)
|
||||
|
||||
boardIDs := []string{}
|
||||
|
@ -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"`
|
||||
}
|
||||
|
@ -261,6 +261,21 @@ func (mr *MockAPIMockRecorder) CreateTeamMembersGracefully(arg0, arg1, arg2 inte
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTeamMembersGracefully", reflect.TypeOf((*MockAPI)(nil).CreateTeamMembersGracefully), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// CreateUploadSession mocks base method.
|
||||
func (m *MockAPI) CreateUploadSession(arg0 *model.UploadSession) (*model.UploadSession, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateUploadSession", arg0)
|
||||
ret0, _ := ret[0].(*model.UploadSession)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateUploadSession indicates an expected call of CreateUploadSession.
|
||||
func (mr *MockAPIMockRecorder) CreateUploadSession(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUploadSession", reflect.TypeOf((*MockAPI)(nil).CreateUploadSession), arg0)
|
||||
}
|
||||
|
||||
// CreateUser mocks base method.
|
||||
func (m *MockAPI) CreateUser(arg0 *model.User) (*model.User, *model.AppError) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -1440,6 +1455,21 @@ func (mr *MockAPIMockRecorder) GetUnsanitizedConfig() *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnsanitizedConfig", reflect.TypeOf((*MockAPI)(nil).GetUnsanitizedConfig))
|
||||
}
|
||||
|
||||
// GetUploadSession mocks base method.
|
||||
func (m *MockAPI) GetUploadSession(arg0 string) (*model.UploadSession, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUploadSession", arg0)
|
||||
ret0, _ := ret[0].(*model.UploadSession)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUploadSession indicates an expected call of GetUploadSession.
|
||||
func (mr *MockAPIMockRecorder) GetUploadSession(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUploadSession", reflect.TypeOf((*MockAPI)(nil).GetUploadSession), arg0)
|
||||
}
|
||||
|
||||
// GetUser mocks base method.
|
||||
func (m *MockAPI) GetUser(arg0 string) (*model.User, *model.AppError) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -2031,6 +2061,20 @@ func (mr *MockAPIMockRecorder) ReadFile(arg0 interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockAPI)(nil).ReadFile), arg0)
|
||||
}
|
||||
|
||||
// RegisterCollectionAndTopic mocks base method.
|
||||
func (m *MockAPI) RegisterCollectionAndTopic(arg0, arg1 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "RegisterCollectionAndTopic", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// RegisterCollectionAndTopic indicates an expected call of RegisterCollectionAndTopic.
|
||||
func (mr *MockAPIMockRecorder) RegisterCollectionAndTopic(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterCollectionAndTopic", reflect.TypeOf((*MockAPI)(nil).RegisterCollectionAndTopic), arg0, arg1)
|
||||
}
|
||||
|
||||
// RegisterCommand mocks base method.
|
||||
func (m *MockAPI) RegisterCommand(arg0 *model.Command) error {
|
||||
m.ctrl.T.Helper()
|
||||
@ -2581,6 +2625,21 @@ func (mr *MockAPIMockRecorder) UpdateUserStatus(arg0, arg1 interface{}) *gomock.
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserStatus", reflect.TypeOf((*MockAPI)(nil).UpdateUserStatus), arg0, arg1)
|
||||
}
|
||||
|
||||
// UploadData mocks base method.
|
||||
func (m *MockAPI) UploadData(arg0 *model.UploadSession, arg1 io.Reader) (*model.FileInfo, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UploadData", arg0, arg1)
|
||||
ret0, _ := ret[0].(*model.FileInfo)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UploadData indicates an expected call of UploadData.
|
||||
func (mr *MockAPIMockRecorder) UploadData(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadData", reflect.TypeOf((*MockAPI)(nil).UploadData), arg0, arg1)
|
||||
}
|
||||
|
||||
// UploadFile mocks base method.
|
||||
func (m *MockAPI) UploadFile(arg0 []byte, arg1, arg2 string) (*model.FileInfo, *model.AppError) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -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()),
|
||||
)
|
||||
|
||||
|
@ -1 +1 @@
|
||||
v16.3.0
|
||||
v16.10.0
|
||||
|
@ -1,5 +1,15 @@
|
||||
{
|
||||
"AppBar.Tooltip": "Verknüpfte Boards umschalten",
|
||||
"Attachment.Attachment-title": "Anhang",
|
||||
"AttachmentBlock.DeleteAction": "Löschen",
|
||||
"AttachmentBlock.addElement": "{type} hinzufügen",
|
||||
"AttachmentBlock.delete": "Anhang erfolgreich gelöscht.",
|
||||
"AttachmentBlock.failed": "Kann Datei nicht hochladen. Limit für Dateigröße erreicht.",
|
||||
"AttachmentBlock.upload": "Anhang wird hochgeladen.",
|
||||
"AttachmentBlock.uploadSuccess": "Anhang erfolgreich hochgeladen.",
|
||||
"AttachmentElement.delete-confirmation-dialog-button-text": "Löschen",
|
||||
"AttachmentElement.download": "Herunterladen",
|
||||
"AttachmentElement.upload-percentage": "Hochladen...({uploadPercent}%)",
|
||||
"BoardComponent.add-a-group": "+ Hinzufügen einer Gruppe",
|
||||
"BoardComponent.delete": "Löschen",
|
||||
"BoardComponent.hidden-columns": "Versteckte Spalten",
|
||||
@ -16,7 +26,7 @@
|
||||
"BoardMember.unlinkChannel": "Verknüpfung aufheben",
|
||||
"BoardPage.newVersion": "Eine neue Version von Boards ist verfügbar, klicke hier, um neu zu laden.",
|
||||
"BoardPage.syncFailed": "Das Board kann gelöscht oder der Zugang entzogen werden.",
|
||||
"BoardTemplateSelector.add-template": "Neue Vorlage",
|
||||
"BoardTemplateSelector.add-template": "Neue Vorlage erstellen",
|
||||
"BoardTemplateSelector.create-empty-board": "Leeres Board erstellen",
|
||||
"BoardTemplateSelector.delete-template": "Löschen",
|
||||
"BoardTemplateSelector.description": "Füge ein Board hinzu, indem du eine der unten definierten Vorlagen verwendest oder ganz neu beginnst.",
|
||||
@ -71,6 +81,7 @@
|
||||
"CardBadges.title-checkboxes": "Checkboxen",
|
||||
"CardBadges.title-comments": "Kommentare",
|
||||
"CardBadges.title-description": "Diese Karte hat eine Beschreibung",
|
||||
"CardDetail.Attach": "Anhängen",
|
||||
"CardDetail.Follow": "Folgen",
|
||||
"CardDetail.Following": "Folgend",
|
||||
"CardDetail.add-content": "Inhalt hinzufügen",
|
||||
@ -92,6 +103,7 @@
|
||||
"CardDetailProperty.property-deleted": "{propertyName} erfolgreich gelöscht!",
|
||||
"CardDetailProperty.property-name-change-subtext": "Typ von \"{oldPropType}\" zu \"{newPropType}\"",
|
||||
"CardDetial.limited-link": "Erfahre mehr über unsere Pläne.",
|
||||
"CardDialog.delete-confirmation-dialog-attachment": "Bestätige das Löschen des Anhangs!",
|
||||
"CardDialog.delete-confirmation-dialog-button-text": "Löschen",
|
||||
"CardDialog.delete-confirmation-dialog-heading": "Karte wirklich löschen!",
|
||||
"CardDialog.editing-template": "Du bearbeitest eine Vorlage.",
|
||||
@ -119,6 +131,7 @@
|
||||
"ContentBlock.editText": "Text bearbeiten ...",
|
||||
"ContentBlock.image": "Bild",
|
||||
"ContentBlock.insertAbove": "Darüber einfügen",
|
||||
"ContentBlock.moveBlock": "Karteninhalt verschieben",
|
||||
"ContentBlock.moveDown": "Nach unten bewegen",
|
||||
"ContentBlock.moveUp": "Nach oben bewegen",
|
||||
"ContentBlock.text": "Text",
|
||||
@ -235,6 +248,8 @@
|
||||
"Sidebar.import-archive": "Archiv importieren",
|
||||
"Sidebar.invite-users": "Nutzer einladen",
|
||||
"Sidebar.logout": "Ausloggen",
|
||||
"Sidebar.new-category.badge": "Neu",
|
||||
"Sidebar.new-category.drag-boards-cta": "Board hierher ziehen...",
|
||||
"Sidebar.no-boards-in-category": "Keine Boards vorhanden",
|
||||
"Sidebar.product-tour": "Produkttour",
|
||||
"Sidebar.random-icons": "Zufällige Icons",
|
||||
@ -269,6 +284,7 @@
|
||||
"TableHeaderMenu.insert-right": "Rechts einfügen",
|
||||
"TableHeaderMenu.sort-ascending": "Aufsteigend sortieren",
|
||||
"TableHeaderMenu.sort-descending": "Absteigend sortieren",
|
||||
"TableRow.MoreOption": "Weitere Aktionen",
|
||||
"TableRow.delete": "Entfernen",
|
||||
"TableRow.open": "Öffnen",
|
||||
"TopBar.give-feedback": "Feedback geben",
|
||||
|
@ -114,6 +114,7 @@
|
||||
"Categories.CreateCategoryDialog.UpdateText": "Update",
|
||||
"CenterPanel.Login": "Login",
|
||||
"CenterPanel.Share": "Share",
|
||||
"ChannelIntro.CreateBoard": "Create a board",
|
||||
"CloudMessage.cloud-server": "Get your own free cloud server.",
|
||||
"ColorOption.selectColor": "Select {color} Color",
|
||||
"Comment.delete": "Delete",
|
||||
@ -248,6 +249,8 @@
|
||||
"Sidebar.import-archive": "Import archive",
|
||||
"Sidebar.invite-users": "Invite users",
|
||||
"Sidebar.logout": "Log out",
|
||||
"Sidebar.new-category.badge": "New",
|
||||
"Sidebar.new-category.drag-boards-cta": "Drag boards here...",
|
||||
"Sidebar.no-boards-in-category": "No boards inside",
|
||||
"Sidebar.product-tour": "Product tour",
|
||||
"Sidebar.random-icons": "Random icons",
|
||||
@ -282,6 +285,7 @@
|
||||
"TableHeaderMenu.insert-right": "Insert right",
|
||||
"TableHeaderMenu.sort-ascending": "Sort ascending",
|
||||
"TableHeaderMenu.sort-descending": "Sort descending",
|
||||
"TableRow.MoreOption": "More actions",
|
||||
"TableRow.delete": "Delete",
|
||||
"TableRow.open": "Open",
|
||||
"TopBar.give-feedback": "Give feedback",
|
||||
@ -435,4 +439,4 @@
|
||||
"tutorial_tip.ok": "Next",
|
||||
"tutorial_tip.out": "Opt out of these tips.",
|
||||
"tutorial_tip.seen": "Seen this before?"
|
||||
}
|
||||
}
|
@ -1,5 +1,14 @@
|
||||
{
|
||||
"AppBar.Tooltip": "Toggle Linked Boards",
|
||||
"Attachment.Attachment-title": "Attachment",
|
||||
"AttachmentBlock.addElement": "add {type}",
|
||||
"AttachmentBlock.delete": "Attachment deleted successfully.",
|
||||
"AttachmentBlock.failed": "Unable to upload the file. Attachment size limit reached.",
|
||||
"AttachmentBlock.upload": "Attachment uploading.",
|
||||
"AttachmentBlock.uploadSuccess": "Attachment uploaded successfully.",
|
||||
"AttachmentElement.delete-confirmation-dialog-button-text": "Delete",
|
||||
"AttachmentElement.download": "Download",
|
||||
"AttachmentElement.upload-percentage": "Uploading...({uploadPercent}%)",
|
||||
"BoardComponent.add-a-group": "+ Add a group",
|
||||
"BoardComponent.delete": "Delete",
|
||||
"BoardComponent.hidden-columns": "Hidden columns",
|
||||
@ -16,7 +25,7 @@
|
||||
"BoardMember.unlinkChannel": "Unlink",
|
||||
"BoardPage.newVersion": "A new version of Boards is available, click here to reload.",
|
||||
"BoardPage.syncFailed": "Board may be deleted or access revoked.",
|
||||
"BoardTemplateSelector.add-template": "New template",
|
||||
"BoardTemplateSelector.add-template": "Create new template",
|
||||
"BoardTemplateSelector.create-empty-board": "Create empty board",
|
||||
"BoardTemplateSelector.delete-template": "Delete",
|
||||
"BoardTemplateSelector.description": "Add a board to the sidebar using any of the templates defined below or start from scratch.",
|
||||
@ -71,6 +80,7 @@
|
||||
"CardBadges.title-checkboxes": "Checkboxes",
|
||||
"CardBadges.title-comments": "Comments",
|
||||
"CardBadges.title-description": "This card has a description",
|
||||
"CardDetail.Attach": "Attach",
|
||||
"CardDetail.Follow": "Follow",
|
||||
"CardDetail.Following": "Following",
|
||||
"CardDetail.add-content": "Add content",
|
||||
@ -92,6 +102,7 @@
|
||||
"CardDetailProperty.property-deleted": "{propertyName} deleted successfully!",
|
||||
"CardDetailProperty.property-name-change-subtext": "type from '{oldPropType}' to '{newPropType}'",
|
||||
"CardDetial.limited-link": "Learn more about our plans.",
|
||||
"CardDialog.delete-confirmation-dialog-attachment": "Confirm attachment deletion",
|
||||
"CardDialog.delete-confirmation-dialog-button-text": "Delete",
|
||||
"CardDialog.delete-confirmation-dialog-heading": "Confirm card deletion?",
|
||||
"CardDialog.editing-template": "You're editing a template.",
|
||||
@ -235,6 +246,8 @@
|
||||
"Sidebar.import-archive": "Import archive",
|
||||
"Sidebar.invite-users": "Invite users",
|
||||
"Sidebar.logout": "Log out",
|
||||
"Sidebar.new-category.badge": "New",
|
||||
"Sidebar.new-category.drag-boards-cta": "Drag boards here...",
|
||||
"Sidebar.no-boards-in-category": "No boards inside",
|
||||
"Sidebar.product-tour": "Product tour",
|
||||
"Sidebar.random-icons": "Random icons",
|
||||
@ -269,6 +282,7 @@
|
||||
"TableHeaderMenu.insert-right": "Insert right",
|
||||
"TableHeaderMenu.sort-ascending": "Sort ascending",
|
||||
"TableHeaderMenu.sort-descending": "Sort descending",
|
||||
"TableRow.MoreOption": "More actions",
|
||||
"TableRow.delete": "Delete",
|
||||
"TableRow.open": "Open",
|
||||
"TopBar.give-feedback": "Give feedback",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user