1
0
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:
Mattermost Build 2023-01-11 20:00:10 +02:00 committed by GitHub
commit 12f6dedc5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
170 changed files with 7540 additions and 3016 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,6 +55,7 @@ import wsClient, {
ACTION_UPDATE_CATEGORY,
ACTION_UPDATE_BOARD_CATEGORY,
ACTION_UPDATE_BOARD,
ACTION_REORDER_CATEGORIES,
} from './../../../webapp/src/wsclient'
import manifest from './manifest'
@ -209,6 +210,8 @@ export default class Plugin {
this.registry?.registerWebSocketEventHandler(`custom_${productID}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${productID}_${ACTION_UPDATE_CARD_LIMIT_TIMESTAMP}`, (e: any) => wsClient.updateCardLimitTimestampHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${productID}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${productID}_${ACTION_REORDER_CATEGORIES}`, (e) => wsClient.updateHandler(e.data))
this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data))
this.registry?.registerPostTypeComponent('custom_cloud_upgrade_nudge', CloudUpgradeNudge)
this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => {
@ -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) {

View File

@ -14,9 +14,11 @@ import (
func (a *API) registerCategoriesRoutes(r *mux.Router) {
// Category APIs
r.HandleFunc("/teams/{teamID}/categories", a.sessionRequired(a.handleCreateCategory)).Methods(http.MethodPost)
r.HandleFunc("/teams/{teamID}/categories/reorder", a.sessionRequired(a.handleReorderCategories)).Methods(http.MethodPut)
r.HandleFunc("/teams/{teamID}/categories/{categoryID}", a.sessionRequired(a.handleUpdateCategory)).Methods(http.MethodPut)
r.HandleFunc("/teams/{teamID}/categories/{categoryID}", a.sessionRequired(a.handleDeleteCategory)).Methods(http.MethodDelete)
r.HandleFunc("/teams/{teamID}/categories", a.sessionRequired(a.handleGetUserCategoryBoards)).Methods(http.MethodGet)
r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/reorder", a.sessionRequired(a.handleReorderCategoryBoards)).Methods(http.MethodPut)
r.HandleFunc("/teams/{teamID}/categories/{categoryID}/boards/{boardID}", a.sessionRequired(a.handleUpdateCategoryBoard)).Methods(http.MethodPost)
}
@ -353,7 +355,7 @@ func (a *API) handleUpdateCategoryBoard(w http.ResponseWriter, r *http.Request)
userID := session.UserID
// TODO: Check the category and the team matches
err := a.app.AddUpdateUserCategoryBoard(teamID, userID, categoryID, boardID)
err := a.app.AddUpdateUserCategoryBoard(teamID, userID, map[string]string{boardID: categoryID})
if err != nil {
a.errorResponse(w, r, err)
return
@ -362,3 +364,160 @@ func (a *API) handleUpdateCategoryBoard(w http.ResponseWriter, r *http.Request)
jsonBytesResponse(w, http.StatusOK, []byte("ok"))
auditRec.Success()
}
func (a *API) handleReorderCategories(w http.ResponseWriter, r *http.Request) {
// swagger:operation PUT /teams/{teamID}/categories/reorder handleReorderCategories
//
// Updated sidebar category order
//
// ---
// produces:
// - application/json
// parameters:
// - name: teamID
// in: path
// description: Team ID
// required: true
// type: string
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
vars := mux.Vars(r)
teamID := vars["teamID"]
ctx := r.Context()
session := ctx.Value(sessionContextKey).(*model.Session)
userID := session.UserID
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
a.errorResponse(w, r, model.NewErrPermission("access denied to category"))
return
}
requestBody, err := io.ReadAll(r.Body)
if err != nil {
a.errorResponse(w, r, err)
return
}
var newCategoryOrder []string
err = json.Unmarshal(requestBody, &newCategoryOrder)
if err != nil {
a.errorResponse(w, r, err)
return
}
auditRec := a.makeAuditRecord(r, "reorderCategories", audit.Fail)
defer a.audit.LogRecord(audit.LevelModify, auditRec)
auditRec.AddMeta("TeamID", teamID)
auditRec.AddMeta("CategoryCount", len(newCategoryOrder))
updatedCategoryOrder, err := a.app.ReorderCategories(userID, teamID, newCategoryOrder)
if err != nil {
a.errorResponse(w, r, err)
return
}
data, err := json.Marshal(updatedCategoryOrder)
if err != nil {
a.errorResponse(w, r, err)
return
}
jsonBytesResponse(w, http.StatusOK, data)
auditRec.Success()
}
func (a *API) handleReorderCategoryBoards(w http.ResponseWriter, r *http.Request) {
// swagger:operation PUT /teams/{teamID}/categories/{categoryID}/boards/reorder handleReorderCategoryBoards
//
// Updates order of boards inside a sidebar category
//
// ---
// produces:
// - application/json
// parameters:
// - name: teamID
// in: path
// description: Team ID
// required: true
// type: string
// - name: categoryID
// in: path
// description: Category ID
// required: true
// type: string
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
vars := mux.Vars(r)
teamID := vars["teamID"]
categoryID := vars["categoryID"]
ctx := r.Context()
session := ctx.Value(sessionContextKey).(*model.Session)
userID := session.UserID
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
a.errorResponse(w, r, model.NewErrPermission("access denied to category"))
return
}
category, err := a.app.GetCategory(categoryID)
if err != nil {
a.errorResponse(w, r, err)
return
}
if category.UserID != userID {
a.errorResponse(w, r, model.NewErrPermission("access denied to category"))
return
}
requestBody, err := io.ReadAll(r.Body)
if err != nil {
a.errorResponse(w, r, err)
return
}
var newBoardsOrder []string
err = json.Unmarshal(requestBody, &newBoardsOrder)
if err != nil {
a.errorResponse(w, r, err)
return
}
auditRec := a.makeAuditRecord(r, "reorderCategoryBoards", audit.Fail)
defer a.audit.LogRecord(audit.LevelModify, auditRec)
updatedBoardsOrder, err := a.app.ReorderCategoryBoards(userID, teamID, categoryID, newBoardsOrder)
if err != nil {
a.errorResponse(w, r, err)
return
}
data, err := json.Marshal(updatedBoardsOrder)
if err != nil {
a.errorResponse(w, r, err)
return
}
jsonBytesResponse(w, http.StatusOK, data)
auditRec.Success()
}

View File

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

View File

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

View File

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

View File

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

View File

@ -175,7 +175,7 @@ func (a *App) setBoardCategoryFromSource(sourceBoardID, destinationBoardID, user
// now that we have source board's category,
// we send destination board to the same category
return a.AddUpdateUserCategoryBoard(teamID, userID, destinationCategoryID, destinationBoardID)
return a.AddUpdateUserCategoryBoard(teamID, userID, map[string]string{destinationBoardID: destinationCategoryID})
}
func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*model.BoardsAndBlocks, []*model.BoardMember, error) {
@ -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) {

View File

@ -52,7 +52,7 @@ func TestAddMemberToBoard(t *testing.T) {
},
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", "board_id_1").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "default_category_id"}).Return(nil)
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
require.NoError(t, err)
@ -126,7 +126,7 @@ func TestAddMemberToBoard(t *testing.T) {
},
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "default_category_id", "board_id_1").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "default_category_id"}).Return(nil)
addedBoardMember, err := th.App.AddMemberToBoard(boardMember)
require.NoError(t, err)
@ -434,9 +434,12 @@ func TestBoardCategory(t *testing.T) {
Name: "Boards",
}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_1").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_2").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "default_category_id", "board_id_3").Return(nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{
"board_id_1": "default_category_id",
"board_id_2": "default_category_id",
"board_id_3": "default_category_id",
}).Return(nil)
boards := []*model.Board{
{ID: "board_id_1"},
@ -449,3 +452,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)
})
}

View File

@ -2,15 +2,21 @@ package app
import (
"errors"
"strings"
"fmt"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/utils"
)
var errCategoryNotFound = errors.New("category ID specified in input does not exist for user")
var errCategoriesLengthMismatch = errors.New("cannot update category order, passed list of categories different size than in database")
var ErrCannotDeleteSystemCategory = errors.New("cannot delete a system category")
var ErrCannotUpdateSystemCategory = errors.New("cannot update a system category")
func (a *App) GetCategory(categoryID string) (*model.Category, error) {
return a.store.GetCategory(categoryID)
}
func (a *App) CreateCategory(category *model.Category) (*model.Category, error) {
category.Hydrate()
if err := category.IsValid(); err != nil {
@ -34,10 +40,8 @@ func (a *App) CreateCategory(category *model.Category) (*model.Category, error)
}
func (a *App) UpdateCategory(category *model.Category) (*model.Category, error) {
// set to default category, UI doesn't create with Type
if strings.TrimSpace(category.Type) == "" {
category.Type = model.CategoryTypeCustom
}
category.Hydrate()
if err := category.IsValid(); err != nil {
return nil, err
}
@ -115,6 +119,10 @@ func (a *App) DeleteCategory(categoryID, userID, teamID string) (*model.Category
return nil, ErrCannotDeleteSystemCategory
}
if err = a.moveBoardsToDefaultCategory(userID, teamID, categoryID); err != nil {
return nil, err
}
if err = a.store.DeleteCategory(categoryID, userID, teamID); err != nil {
return nil, err
}
@ -130,3 +138,109 @@ func (a *App) DeleteCategory(categoryID, userID, teamID string) (*model.Category
return deletedCategory, nil
}
func (a *App) moveBoardsToDefaultCategory(userID, teamID, sourceCategoryID string) error {
// we need a list of boards associated to this category
// so we can move them to user's default Boards category
categoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
if err != nil {
return err
}
var sourceCategoryBoards *model.CategoryBoards
defaultCategoryID := ""
// iterate user's categories to find the source category
// and the default category.
// We need source category to get the list of its board
// and the default category to know its ID to
// move source category's boards to.
for i := range categoryBoards {
if categoryBoards[i].ID == sourceCategoryID {
sourceCategoryBoards = &categoryBoards[i]
}
if categoryBoards[i].Name == defaultCategoryBoards {
defaultCategoryID = categoryBoards[i].ID
}
// if both categories are found, no need to iterate furthur.
if sourceCategoryBoards != nil && defaultCategoryID != "" {
break
}
}
if sourceCategoryBoards == nil {
return errCategoryNotFound
}
if defaultCategoryID == "" {
return fmt.Errorf("moveBoardsToDefaultCategory: %w", errNoDefaultCategoryFound)
}
boardCategoryMapping := map[string]string{}
for _, boardID := range sourceCategoryBoards.BoardIDs {
boardCategoryMapping[boardID] = defaultCategoryID
}
if err := a.AddUpdateUserCategoryBoard(teamID, userID, boardCategoryMapping); err != nil {
return fmt.Errorf("moveBoardsToDefaultCategory: %w", err)
}
return nil
}
func (a *App) ReorderCategories(userID, teamID string, newCategoryOrder []string) ([]string, error) {
if err := a.verifyNewCategoriesMatchExisting(userID, teamID, newCategoryOrder); err != nil {
return nil, err
}
newOrder, err := a.store.ReorderCategories(userID, teamID, newCategoryOrder)
if err != nil {
return nil, err
}
go func() {
a.wsAdapter.BroadcastCategoryReorder(teamID, userID, newOrder)
}()
return newOrder, nil
}
func (a *App) verifyNewCategoriesMatchExisting(userID, teamID string, newCategoryOrder []string) error {
existingCategories, err := a.store.GetUserCategories(userID, teamID)
if err != nil {
return err
}
if len(newCategoryOrder) != len(existingCategories) {
return fmt.Errorf(
"%w length new categories: %d, length existing categories: %d, userID: %s, teamID: %s",
errCategoriesLengthMismatch,
len(newCategoryOrder),
len(existingCategories),
userID,
teamID,
)
}
existingCategoriesMap := map[string]bool{}
for _, category := range existingCategories {
existingCategoriesMap[category.ID] = true
}
for _, newCategoryID := range newCategoryOrder {
if _, found := existingCategoriesMap[newCategoryID]; !found {
return fmt.Errorf(
"%w specified category ID: %s, userID: %s, teamID: %s",
errCategoryNotFound,
newCategoryID,
userID,
teamID,
)
}
}
return nil
}

View File

@ -1,6 +1,7 @@
package app
import (
"errors"
"fmt"
"github.com/mattermost/focalboard/server/model"
@ -8,6 +9,10 @@ import (
const defaultCategoryBoards = "Boards"
var errCategoryBoardsLengthMismatch = errors.New("cannot update category boards order, passed list of categories boards different size than in database")
var errBoardNotFoundInCategory = errors.New("specified board ID not found in specified category ID")
var errBoardMembershipNotFound = errors.New("board membership not found for user's board")
func (a *App) GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error) {
categoryBoards, err := a.store.GetUserCategoryBoards(userID, teamID)
if err != nil {
@ -53,6 +58,7 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
TeamID: teamID,
Collapsed: false,
Type: model.CategoryTypeSystem,
SortOrder: len(existingCategoryBoards) * model.CategoryBoardsSortOrderGap,
}
createdCategory, err := a.CreateCategory(&category)
if err != nil {
@ -61,23 +67,40 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
// once the category is created, we need to move all boards which do not
// belong to any category, into this category.
boardMembers, err := a.GetMembersForUser(userID)
if err != nil {
return nil, fmt.Errorf("createBoardsCategory error fetching user's board memberships: %w", err)
}
boardMemberByBoardID := map[string]*model.BoardMember{}
for _, boardMember := range boardMembers {
boardMemberByBoardID[boardMember.BoardID] = boardMember
}
createdCategoryBoards := &model.CategoryBoards{
Category: *createdCategory,
BoardIDs: []string{},
}
for _, bm := range boardMembers {
// get user's current team's baords
userTeamBoards, err := a.GetBoardsForUserAndTeam(userID, teamID, false)
if err != nil {
return nil, fmt.Errorf("createBoardsCategory error fetching user's team's boards: %w", err)
}
for _, board := range userTeamBoards {
boardMembership, ok := boardMemberByBoardID[board.ID]
if !ok {
return nil, fmt.Errorf("createBoardsCategory: %w", errBoardMembershipNotFound)
}
// boards with implicit access (aka synthetic membership),
// should show up in LHS only when openign them explicitelly.
// So we don't process any synthetic membership boards
// and only add boards with explicit access to, to the the LHS,
// for example, if a user explicitelly added another user to a board.
if bm.Synthetic {
if boardMembership.Synthetic {
continue
}
@ -85,7 +108,7 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
for _, categoryBoard := range existingCategoryBoards {
for _, boardID := range categoryBoard.BoardIDs {
if boardID == bm.BoardID {
if boardID == board.ID {
belongsToCategory = true
break
}
@ -99,36 +122,111 @@ func (a *App) createBoardsCategory(userID, teamID string, existingCategoryBoards
}
if !belongsToCategory {
if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, bm.BoardID); err != nil {
// ToDo: por siaca
// if err := a.AddUpdateUserCategoryBoard(teamID, userID, createdCategory.ID, board.ID); err != nil {
// ---
if err := a.AddUpdateUserCategoryBoard(teamID, userID, map[string]string{board.ID: createdCategory.ID}); err != nil {
return nil, fmt.Errorf("createBoardsCategory failed to add category-less board to the default category, defaultCategoryID: %s, error: %w", createdCategory.ID, err)
}
createdCategoryBoards.BoardIDs = append(createdCategoryBoards.BoardIDs, bm.BoardID)
createdCategoryBoards.BoardIDs = append(createdCategoryBoards.BoardIDs, board.ID)
}
}
return createdCategoryBoards, nil
}
func (a *App) AddUpdateUserCategoryBoard(teamID, userID, categoryID, boardID string) error {
err := a.store.AddUpdateCategoryBoard(userID, categoryID, boardID)
func (a *App) AddUpdateUserCategoryBoard(teamID, userID string, boardCategoryMapping map[string]string) error {
err := a.store.AddUpdateCategoryBoard(userID, boardCategoryMapping)
if err != nil {
return err
}
wsPayload := make([]*model.BoardCategoryWebsocketData, len(boardCategoryMapping))
i := 0
for boardID, categoryID := range boardCategoryMapping {
wsPayload[i] = &model.BoardCategoryWebsocketData{
BoardID: boardID,
CategoryID: categoryID,
}
i++
}
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastCategoryBoardChange(
teamID,
userID,
model.BoardCategoryWebsocketData{
BoardID: boardID,
CategoryID: categoryID,
})
wsPayload,
)
return nil
})
return nil
}
func (a *App) ReorderCategoryBoards(userID, teamID, categoryID string, newBoardsOrder []string) ([]string, error) {
if err := a.verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID, newBoardsOrder); err != nil {
return nil, err
}
newOrder, err := a.store.ReorderCategoryBoards(categoryID, newBoardsOrder)
if err != nil {
return nil, err
}
go func() {
a.wsAdapter.BroadcastCategoryBoardsReorder(teamID, userID, categoryID, newOrder)
}()
return newOrder, nil
}
func (a *App) verifyNewCategoryBoardsMatchExisting(userID, teamID, categoryID string, newBoardsOrder []string) error {
// this function is to ensure that we don't miss specifying
// all boards of the category while reordering.
existingCategoryBoards, err := a.GetUserCategoryBoards(userID, teamID)
if err != nil {
return err
}
var targetCategoryBoards *model.CategoryBoards
for i := range existingCategoryBoards {
if existingCategoryBoards[i].Category.ID == categoryID {
targetCategoryBoards = &existingCategoryBoards[i]
break
}
}
if targetCategoryBoards == nil {
return fmt.Errorf("%w categoryID: %s", errCategoryNotFound, categoryID)
}
if len(targetCategoryBoards.BoardIDs) != len(newBoardsOrder) {
return fmt.Errorf(
"%w length new category boards: %d, length existing category boards: %d, userID: %s, teamID: %s, categoryID: %s",
errCategoryBoardsLengthMismatch,
len(newBoardsOrder),
len(targetCategoryBoards.BoardIDs),
userID,
teamID,
categoryID,
)
}
existingBoardMap := map[string]bool{}
for _, boardID := range targetCategoryBoards.BoardIDs {
existingBoardMap[boardID] = true
}
for _, boardID := range newBoardsOrder {
if _, found := existingBoardMap[boardID]; !found {
return fmt.Errorf(
"%w board ID: %s, category ID: %s, userID: %s, teamID: %s",
errBoardNotFoundInCategory,
boardID,
categoryID,
userID,
teamID,
)
}
}
return nil
}

View File

@ -21,6 +21,20 @@ func TestGetUserCategoryBoards(t *testing.T) {
Name: "Boards",
}, nil)
board1 := &model.Board{
ID: "board_id_1",
}
board2 := &model.Board{
ID: "board_id_2",
}
board3 := &model.Board{
ID: "board_id_3",
}
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
@ -35,10 +49,9 @@ func TestGetUserCategoryBoards(t *testing.T) {
Synthetic: false,
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_1").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_2").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_3").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_2": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_3": "boards_category_id"}).Return(nil)
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
@ -59,6 +72,7 @@ func TestGetUserCategoryBoards(t *testing.T) {
}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
categoryBoards, err := th.App.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
@ -94,6 +108,7 @@ func TestCreateBoardsCategory(t *testing.T) {
Type: "system",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
existingCategoryBoards := []model.CategoryBoards{}
@ -111,6 +126,7 @@ func TestCreateBoardsCategory(t *testing.T) {
Type: "system",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
@ -144,6 +160,17 @@ func TestCreateBoardsCategory(t *testing.T) {
Type: "system",
Name: "Boards",
}, nil)
board1 := &model.Board{
ID: "board_id_1",
}
board2 := &model.Board{
ID: "board_id_2",
}
board3 := &model.Board{
ID: "board_id_3",
}
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1, board2, board3}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
@ -158,9 +185,9 @@ func TestCreateBoardsCategory(t *testing.T) {
Synthetic: false,
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_1").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_2").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_3").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_2": "boards_category_id"}).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_3": "boards_category_id"}).Return(nil)
existingCategoryBoards := []model.CategoryBoards{}
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
@ -180,6 +207,11 @@ func TestCreateBoardsCategory(t *testing.T) {
Type: "system",
Name: "Boards",
}, nil)
board1 := &model.Board{
ID: "board_id_1",
}
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{board1}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{
{
BoardID: "board_id_1",
@ -194,7 +226,7 @@ func TestCreateBoardsCategory(t *testing.T) {
Synthetic: true,
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", "boards_category_id", "board_id_1").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
existingCategoryBoards := []model.CategoryBoards{}
boardsCategory, err := th.App.createBoardsCategory("user_id", "team_id", existingCategoryBoards)
@ -208,3 +240,54 @@ func TestCreateBoardsCategory(t *testing.T) {
assert.Equal(t, 1, len(boardsCategory.BoardIDs))
})
}
func TestReorderCategoryBoards(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
BoardIDs: []string{"board_id_1", "board_id_2"},
},
{
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
BoardIDs: []string{"board_id_3"},
},
{
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
BoardIDs: []string{},
},
}, nil)
th.Store.EXPECT().ReorderCategoryBoards("category_id_1", []string{"board_id_2", "board_id_1"}).Return([]string{"board_id_2", "board_id_1"}, nil)
newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"})
assert.NoError(t, err)
assert.Equal(t, 2, len(newOrder))
assert.Equal(t, "board_id_2", newOrder[0])
assert.Equal(t, "board_id_1", newOrder[1])
})
t.Run("not specifying all boards", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{ID: "category_id_1", Name: "Category 1"},
BoardIDs: []string{"board_id_1", "board_id_2", "board_id_3"},
},
{
Category: model.Category{ID: "category_id_2", Name: "Boards", Type: "system"},
BoardIDs: []string{"board_id_3"},
},
{
Category: model.Category{ID: "category_id_3", Name: "Category 3"},
BoardIDs: []string{},
},
}, nil)
newOrder, err := th.App.ReorderCategoryBoards("user_id", "team_id", "category_id_1", []string{"board_id_2", "board_id_1"})
assert.Error(t, err)
assert.Nil(t, newOrder)
})
}

View File

@ -92,6 +92,14 @@ func TestUpdateCategory(t *testing.T) {
})
t.Run("updating invalid category", func(t *testing.T) {
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "category_id_1",
Name: "Category",
TeamID: "team_id_1",
UserID: "user_id_1",
Type: "custom",
}, nil)
category := &model.Category{
ID: "category_id_1",
Name: "Name",
@ -260,6 +268,33 @@ func TestDeleteCategory(t *testing.T) {
DeleteAt: 10000,
}, nil)
th.Store.EXPECT().GetUserCategoryBoards("user_id_1", "team_id_1").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "category_id_default",
DeleteAt: 0,
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "default",
Name: "Boards",
},
BoardIDs: []string{},
},
{
Category: model.Category{
ID: "category_id_1",
DeleteAt: 0,
UserID: "user_id_1",
TeamID: "team_id_1",
Type: "custom",
Name: "Category 1",
},
BoardIDs: []string{},
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", utils.Anything).Return(nil)
deletedCategory, err := th.App.DeleteCategory("category_id_1", "user_id_1", "team_id_1")
assert.NotNil(t, deletedCategory)
assert.NoError(t, err)
@ -293,3 +328,171 @@ func TestDeleteCategory(t *testing.T) {
assert.Error(t, err)
})
}
func TestMoveBoardsToDefaultCategory(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("When default category already exists", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "category_id_1",
Name: "Boards",
Type: "system",
},
},
{
Category: model.Category{
ID: "category_id_2",
Name: "Custom Category 1",
Type: "custom",
},
},
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", utils.Anything).Return(nil)
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
assert.NoError(t, err)
})
t.Run("When default category doesn't already exists", func(t *testing.T) {
th.Store.EXPECT().GetUserCategoryBoards("user_id", "team_id").Return([]model.CategoryBoards{
{
Category: model.Category{
ID: "category_id_2",
Name: "Custom Category 1",
Type: "custom",
},
},
}, nil)
th.Store.EXPECT().CreateCategory(utils.Anything).Return(nil)
th.Store.EXPECT().GetCategory(utils.Anything).Return(&model.Category{
ID: "default_category_id",
Name: "Boards",
Type: "system",
}, nil)
th.Store.EXPECT().GetMembersForUser("user_id").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id", "team_id", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id", utils.Anything).Return(nil)
err := th.App.moveBoardsToDefaultCategory("user_id", "team_id", "category_id_2")
assert.NoError(t, err)
})
}
func TestReorderCategories(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
{
ID: "category_id_1",
Name: "Boards",
Type: "system",
},
{
ID: "category_id_2",
Name: "Category 2",
Type: "custom",
},
{
ID: "category_id_3",
Name: "Category 3",
Type: "custom",
},
}, nil)
th.Store.EXPECT().ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"}).
Return([]string{"category_id_2", "category_id_3", "category_id_1"}, nil)
newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3", "category_id_1"})
assert.NoError(t, err)
assert.Equal(t, 3, len(newOrder))
})
t.Run("not specifying all categories should fail", func(t *testing.T) {
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
{
ID: "category_id_1",
Name: "Boards",
Type: "system",
},
{
ID: "category_id_2",
Name: "Category 2",
Type: "custom",
},
{
ID: "category_id_3",
Name: "Category 3",
Type: "custom",
},
}, nil)
newOrder, err := th.App.ReorderCategories("user_id", "team_id", []string{"category_id_2", "category_id_3"})
assert.Error(t, err)
assert.Nil(t, newOrder)
})
}
func TestVerifyNewCategoriesMatchExisting(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("base case", func(t *testing.T) {
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
{
ID: "category_id_1",
Name: "Boards",
Type: "system",
},
{
ID: "category_id_2",
Name: "Category 2",
Type: "custom",
},
{
ID: "category_id_3",
Name: "Category 3",
Type: "custom",
},
}, nil)
err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{
"category_id_2",
"category_id_3",
"category_id_1",
})
assert.NoError(t, err)
})
t.Run("different category counts", func(t *testing.T) {
th.Store.EXPECT().GetUserCategories("user_id", "team_id").Return([]model.Category{
{
ID: "category_id_1",
Name: "Boards",
Type: "system",
},
{
ID: "category_id_2",
Name: "Category 2",
Type: "custom",
},
{
ID: "category_id_3",
Name: "Category 3",
Type: "custom",
},
}, nil)
err := th.App.verifyNewCategoriesMatchExisting("user_id", "team_id", []string{
"category_id_2",
"category_id_3",
})
assert.Error(t, err)
})
}

View File

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

View File

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

View File

@ -55,8 +55,9 @@ func TestApp_ImportArchive(t *testing.T) {
ID: "boards_category_id",
Name: "Boards",
}, nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user", "test-team", false).Return([]*model.Board{}, nil)
th.Store.EXPECT().GetMembersForUser("user").Return([]*model.BoardMember{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user", "boards_category_id", utils.Anything).Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user", utils.Anything).Return(nil)
err := th.App.ImportArchive(r, opts)
require.NoError(t, err, "import archive should not fail")

View File

@ -77,7 +77,8 @@ func TestPrepareOnboardingTour(t *testing.T) {
ID: "boards_category",
Name: "Boards",
}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", "board_id_2").Return(nil)
th.Store.EXPECT().GetBoardsForUserAndTeam("user_id_1", teamID, false).Return([]*model.Board{}, nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_2": "boards_category_id"}).Return(nil)
teamID, boardID, err := th.App.PrepareOnboardingTour(userID, teamID)
assert.NoError(t, err)
@ -120,7 +121,7 @@ func TestCreateWelcomeBoard(t *testing.T) {
Category: model.Category{ID: "boards_category_id", Name: "Boards"},
},
}, nil).Times(2)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", "boards_category_id", "board_id_1").Return(nil)
th.Store.EXPECT().AddUpdateCategoryBoard("user_id_1", map[string]string{"board_id_1": "boards_category_id"}).Return(nil)
boardID, err := th.App.createWelcomeBoard(userID, teamID)
assert.Nil(t, err)

View File

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

View File

@ -443,6 +443,15 @@ func (c *Client) CreateCategory(category model.Category) (*model.Category, *Resp
return model.CategoryFromJSON(r.Body), BuildResponse(r)
}
func (c *Client) DeleteCategory(teamID, categoryID string) *Response {
r, err := c.DoAPIDelete(c.GetTeamRoute(teamID)+"/categories/"+categoryID, "")
if err != nil {
return BuildErrorResponse(r, err)
}
return BuildResponse(r)
}
func (c *Client) UpdateCategoryBoard(teamID, categoryID, boardID string) *Response {
r, err := c.DoAPIPost(fmt.Sprintf("%s/categories/%s/boards/%s", c.GetTeamRoute(teamID), categoryID, boardID), "")
if err != nil {
@ -465,6 +474,30 @@ func (c *Client) GetUserCategoryBoards(teamID string) ([]model.CategoryBoards, *
return categoryBoards, BuildResponse(r)
}
func (c *Client) ReorderCategories(teamID string, newOrder []string) ([]string, *Response) {
r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/reorder", toJSON(newOrder))
if err != nil {
return nil, BuildErrorResponse(r, err)
}
defer closeBody(r)
var updatedCategoryOrder []string
_ = json.NewDecoder(r.Body).Decode(&updatedCategoryOrder)
return updatedCategoryOrder, BuildResponse(r)
}
func (c *Client) ReorderCategoryBoards(teamID, categoryID string, newOrder []string) ([]string, *Response) {
r, err := c.DoAPIPut(c.GetTeamRoute(teamID)+"/categories/"+categoryID+"/reorder", toJSON(newOrder))
if err != nil {
return nil, BuildErrorResponse(r, err)
}
defer closeBody(r)
var updatedBoardsOrder []string
_ = json.NewDecoder(r.Body).Decode(&updatedBoardsOrder)
return updatedBoardsOrder, BuildResponse(r)
}
func (c *Client) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks) (*model.BoardsAndBlocks, *Response) {
r, err := c.DoAPIPatch(c.GetBoardsAndBlocksRoute(), toJSON(pbab))
if err != nil {
@ -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 {

View File

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

View File

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

View File

@ -457,6 +457,17 @@ func (th *TestHelper) CreateBoard(teamID string, boardType model.BoardType) *mod
return board
}
func (th *TestHelper) CreateCategory(category model.Category) *model.Category {
cat, resp := th.Client.CreateCategory(category)
th.CheckOK(resp)
return cat
}
func (th *TestHelper) UpdateCategoryBoard(teamID, categoryID, boardID string) {
response := th.Client.UpdateCategoryBoard(teamID, categoryID, boardID)
th.CheckOK(response)
}
func (th *TestHelper) CreateBoardAndCards(teamdID string, boardType model.BoardType, numCards int) (*model.Board, []*model.Card) {
board := th.CreateBoard(teamdID, boardType)
cards := make([]*model.Card, 0, numCards)
@ -482,6 +493,17 @@ func (th *TestHelper) MakeCardProps(count int) map[string]any {
return props
}
func (th *TestHelper) GetUserCategoryBoards(teamID string) []model.CategoryBoards {
categoryBoards, response := th.Client.GetUserCategoryBoards(teamID)
th.CheckOK(response)
return categoryBoards
}
func (th *TestHelper) DeleteCategory(teamID, categoryID string) {
response := th.Client.DeleteCategory(teamID, categoryID)
th.CheckOK(response)
}
func (th *TestHelper) GetUser1() *model.User {
return th.Me(th.Client)
}

View File

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

View File

@ -0,0 +1,54 @@
package integrationtests
import (
"testing"
"github.com/mattermost/focalboard/server/model"
"github.com/stretchr/testify/require"
)
func TestSidebar(t *testing.T) {
th := SetupTestHelperWithToken(t).Start()
defer th.TearDown()
// we'll create a new board.
// The board should end up in a default "Boards" category
board := th.CreateBoard("team-id", "O")
categoryBoards := th.GetUserCategoryBoards("team-id")
require.Equal(t, 1, len(categoryBoards))
require.Equal(t, "Boards", categoryBoards[0].Name)
require.Equal(t, 1, len(categoryBoards[0].BoardIDs))
require.Equal(t, board.ID, categoryBoards[0].BoardIDs[0])
// create a new category, a new board
// and move that board into the new category
board2 := th.CreateBoard("team-id", "O")
category := th.CreateCategory(model.Category{
Name: "Category 2",
TeamID: "team-id",
UserID: "single-user",
})
th.UpdateCategoryBoard("team-id", category.ID, board2.ID)
categoryBoards = th.GetUserCategoryBoards("team-id")
// now there should be two categories - boards and the one
// we created just now
require.Equal(t, 2, len(categoryBoards))
// the newly created category should be the first one array
// as new categories end up on top in LHS
require.Equal(t, "Category 2", categoryBoards[0].Name)
require.Equal(t, 1, len(categoryBoards[0].BoardIDs))
require.Equal(t, board2.ID, categoryBoards[0].BoardIDs[0])
// now we'll delete the custom category we created, "Category 2"
// and all it's boards should get moved to the Boards category
th.DeleteCategory("team-id", category.ID)
categoryBoards = th.GetUserCategoryBoards("team-id")
require.Equal(t, 1, len(categoryBoards))
require.Equal(t, "Boards", categoryBoards[0].Name)
require.Equal(t, 2, len(categoryBoards[0].BoardIDs))
require.Contains(t, categoryBoards[0].BoardIDs, board.ID)
require.Contains(t, categoryBoards[0].BoardIDs, board2.ID)
}

View File

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

View File

@ -49,16 +49,37 @@ type Category struct {
// required: true
Collapsed bool `json:"collapsed"`
// Inter-category sort order per user
// required: true
SortOrder int `json:"sortOrder"`
// The sorting method applied on this category
// required: true
Sorting string `json:"sorting"`
// Category's type
// required: true
Type string `json:"type"`
}
func (c *Category) Hydrate() {
c.ID = utils.NewID(utils.IDTypeNone)
c.CreateAt = utils.GetMillis()
c.UpdateAt = c.CreateAt
if c.Type == "" {
if c.ID == "" {
c.ID = utils.NewID(utils.IDTypeNone)
}
if c.CreateAt == 0 {
c.CreateAt = utils.GetMillis()
}
if c.UpdateAt == 0 {
c.UpdateAt = c.CreateAt
}
if c.SortOrder < 0 {
c.SortOrder = 0
}
if strings.TrimSpace(c.Type) == "" {
c.Type = CategoryTypeCustom
}
}

View File

@ -1,5 +1,7 @@
package model
const CategoryBoardsSortOrderGap = 10
// CategoryBoards is a board category and associated boards
// swagger:model
type CategoryBoards struct {
@ -8,6 +10,10 @@ type CategoryBoards struct {
// The IDs of boards in this category
// required: true
BoardIDs []string `json:"boardIDs"`
// The relative sort order of this board in its category
// required: true
SortOrder int `json:"sortOrder"`
}
type BoardCategoryWebsocketData struct {

View File

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

View File

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

View File

@ -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 = `[
{

View File

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

View File

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

View File

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

View File

@ -37,17 +37,17 @@ func (m *MockStore) EXPECT() *MockStoreMockRecorder {
}
// AddUpdateCategoryBoard mocks base method.
func (m *MockStore) AddUpdateCategoryBoard(arg0, arg1, arg2 string) error {
func (m *MockStore) AddUpdateCategoryBoard(arg0 string, arg1 map[string]string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AddUpdateCategoryBoard", arg0, arg1, arg2)
ret := m.ctrl.Call(m, "AddUpdateCategoryBoard", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// AddUpdateCategoryBoard indicates an expected call of AddUpdateCategoryBoard.
func (mr *MockStoreMockRecorder) AddUpdateCategoryBoard(arg0, arg1, arg2 interface{}) *gomock.Call {
func (mr *MockStoreMockRecorder) AddUpdateCategoryBoard(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUpdateCategoryBoard", reflect.TypeOf((*MockStore)(nil).AddUpdateCategoryBoard), arg0, arg1, arg2)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUpdateCategoryBoard", reflect.TypeOf((*MockStore)(nil).AddUpdateCategoryBoard), arg0, arg1)
}
// CanSeeUser mocks base method.
@ -1133,6 +1133,21 @@ func (mr *MockStoreMockRecorder) GetUserByUsername(arg0 interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUsername", reflect.TypeOf((*MockStore)(nil).GetUserByUsername), arg0)
}
// GetUserCategories mocks base method.
func (m *MockStore) GetUserCategories(arg0, arg1 string) ([]model.Category, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserCategories", arg0, arg1)
ret0, _ := ret[0].([]model.Category)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserCategories indicates an expected call of GetUserCategories.
func (mr *MockStoreMockRecorder) GetUserCategories(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCategories", reflect.TypeOf((*MockStore)(nil).GetUserCategories), arg0, arg1)
}
// GetUserCategoryBoards mocks base method.
func (m *MockStore) GetUserCategoryBoards(arg0, arg1 string) ([]model.CategoryBoards, error) {
m.ctrl.T.Helper()
@ -1382,6 +1397,36 @@ func (mr *MockStoreMockRecorder) RemoveDefaultTemplates(arg0 interface{}) *gomoc
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveDefaultTemplates", reflect.TypeOf((*MockStore)(nil).RemoveDefaultTemplates), arg0)
}
// ReorderCategories mocks base method.
func (m *MockStore) ReorderCategories(arg0, arg1 string, arg2 []string) ([]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ReorderCategories", arg0, arg1, arg2)
ret0, _ := ret[0].([]string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ReorderCategories indicates an expected call of ReorderCategories.
func (mr *MockStoreMockRecorder) ReorderCategories(arg0, arg1, arg2 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReorderCategories", reflect.TypeOf((*MockStore)(nil).ReorderCategories), arg0, arg1, arg2)
}
// ReorderCategoryBoards mocks base method.
func (m *MockStore) ReorderCategoryBoards(arg0 string, arg1 []string) ([]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ReorderCategoryBoards", arg0, arg1)
ret0, _ := ret[0].([]string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ReorderCategoryBoards indicates an expected call of ReorderCategoryBoards.
func (mr *MockStoreMockRecorder) ReorderCategoryBoards(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReorderCategoryBoards", reflect.TypeOf((*MockStore)(nil).ReorderCategoryBoards), arg0, arg1)
}
// RunDataRetention mocks base method.
func (m *MockStore) RunDataRetention(arg0, arg1 int64) (int64, error) {
m.ctrl.T.Helper()
@ -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.

View File

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

View File

@ -2,6 +2,7 @@ package sqlstore
import (
"database/sql"
"fmt"
sq "github.com/Masterminds/squirrel"
"github.com/mattermost/focalboard/server/model"
@ -10,9 +11,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
}

View File

@ -2,6 +2,7 @@ package sqlstore
import (
"database/sql"
"fmt"
sq "github.com/Masterminds/squirrel"
"github.com/mattermost/focalboard/server/model"
@ -41,7 +42,8 @@ func (s *SQLStore) getCategoryBoardAttributes(db sq.BaseRunner, categoryID strin
Where(sq.Eq{
"category_id": categoryID,
"delete_at": 0,
})
}).
OrderBy("sort_order")
rows, err := query.Query()
if err != nil {
@ -52,23 +54,25 @@ func (s *SQLStore) getCategoryBoardAttributes(db sq.BaseRunner, categoryID strin
return s.categoryBoardsFromRows(rows)
}
func (s *SQLStore) addUpdateCategoryBoard(db sq.BaseRunner, userID, categoryID, boardID string) error {
if err := s.deleteUserCategoryBoard(db, userID, boardID); err != nil {
func (s *SQLStore) addUpdateCategoryBoard(db sq.BaseRunner, userID string, boardCategoryMapping map[string]string) error {
boardIDs := []string{}
for boardID := range boardCategoryMapping {
boardIDs = append(boardIDs, boardID)
}
if err := s.deleteUserCategoryBoards(db, userID, boardIDs); err != nil {
return err
}
if categoryID == "0" {
// category ID "0" means user wants to move board out of
// the custom category. Deleting the user-board-category
// mapping achieves this.
return s.addUserCategoryBoard(db, userID, boardCategoryMapping)
}
func (s *SQLStore) addUserCategoryBoard(db sq.BaseRunner, userID string, boardCategoryMapping map[string]string) error {
if len(boardCategoryMapping) == 0 {
return nil
}
return s.addUserCategoryBoard(db, userID, categoryID, boardID)
}
func (s *SQLStore) addUserCategoryBoard(db sq.BaseRunner, userID, categoryID, boardID string) error {
_, err := s.getQueryBuilder(db).
query := s.getQueryBuilder(db).
Insert(s.tablePrefix+"category_boards").
Columns(
"id",
@ -78,39 +82,50 @@ func (s *SQLStore) addUserCategoryBoard(db sq.BaseRunner, userID, categoryID, bo
"create_at",
"update_at",
"delete_at",
).
Values(
utils.NewID(utils.IDTypeNone),
userID,
categoryID,
boardID,
utils.GetMillis(),
utils.GetMillis(),
0,
).Exec()
"sort_order",
)
if err != nil {
now := utils.GetMillis()
for boardID, categoryID := range boardCategoryMapping {
query = query.
Values(
utils.NewID(utils.IDTypeNone),
userID,
categoryID,
boardID,
now,
now,
0,
0,
)
}
if _, err := query.Exec(); err != nil {
s.logger.Error("addUserCategoryBoard error", mlog.Err(err))
return err
}
return nil
}
func (s *SQLStore) deleteUserCategoryBoard(db sq.BaseRunner, userID, boardID string) error {
func (s *SQLStore) deleteUserCategoryBoards(db sq.BaseRunner, userID string, boardIDs []string) error {
if len(boardIDs) == 0 {
return nil
}
_, err := s.getQueryBuilder(db).
Update(s.tablePrefix+"category_boards").
Set("delete_at", utils.GetMillis()).
Where(sq.Eq{
"user_id": userID,
"board_id": boardID,
"board_id": boardIDs,
"delete_at": 0,
}).Exec()
if err != nil {
s.logger.Error(
"deleteUserCategoryBoard delete error",
"deleteUserCategoryBoards delete error",
mlog.String("userID", userID),
mlog.String("boardID", boardID),
mlog.Array("boardID", boardIDs),
mlog.Err(err),
)
return err
@ -134,3 +149,35 @@ func (s *SQLStore) categoryBoardsFromRows(rows *sql.Rows) ([]string, error) {
return blocks, nil
}
func (s *SQLStore) reorderCategoryBoards(db sq.BaseRunner, categoryID string, newBoardsOrder []string) ([]string, error) {
if len(newBoardsOrder) == 0 {
return nil, nil
}
updateCase := sq.Case("board_id")
for i, boardID := range newBoardsOrder {
updateCase = updateCase.When("'"+boardID+"'", sq.Expr(fmt.Sprintf("%d", i+model.CategoryBoardsSortOrderGap)))
}
updateCase.Else("sort_order")
query := s.getQueryBuilder(db).
Update(s.tablePrefix+"category_boards").
Set("sort_order", updateCase).
Where(sq.Eq{
"category_id": categoryID,
"delete_at": 0,
})
if _, err := query.Exec(); err != nil {
s.logger.Error(
"reorderCategoryBoards failed to update category board order",
mlog.String("category_id", categoryID),
mlog.Err(err),
)
return nil, err
}
return newBoardsOrder, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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='';

View File

@ -1 +1,2 @@
ALTER TABLE {{.prefix}}categories ADD collapsed boolean default false;
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
{{ addColumnIfNeeded "categories" "collapsed" "boolean" "default false"}}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
{{ addColumnIfNeeded "categories" "sort_order" "BIGINT" ""}}

View File

@ -0,0 +1,2 @@
{{- /* addColumnIfNeeded tableName columnName datatype constraint */ -}}
{{ addColumnIfNeeded "category_boards" "sort_order" "BIGINT" ""}}

View File

@ -0,0 +1,15 @@
{{- /* To move Boards category to to the last value, we just need a relatively large value. */ -}}
{{- /* Assigning 10x total number of categories works perfectly. The sort_order is anyways updated */ -}}
{{- /* when the user manually DNDs a category. */ -}}
{{if or .postgres .sqlite}}
UPDATE {{.prefix}}categories SET sort_order = (10 * (SELECT COUNT(*) FROM {{.prefix}}categories)) WHERE lower(name) = 'boards';
{{end}}
{{if .mysql}}
{{- /* MySQL doesn't allow referencing the same table in subquery and update query like Postgres, */ -}}
{{- /* So we save the subquery result in a variable to use later. */ -}}
SET @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}}

View 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}}
```

View File

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

View File

@ -26,6 +26,7 @@ type Params struct {
NewMutexFn MutexFactory
ServicesAPI servicesAPI
SkipMigrations bool
ConfigFn func() *mmModel.Config
}
func (p Params) CheckValid() error {

View File

@ -22,15 +22,15 @@ import (
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
func (s *SQLStore) AddUpdateCategoryBoard(userID string, categoryID string, blockID string) error {
func (s *SQLStore) AddUpdateCategoryBoard(userID string, boardCategoryMapping map[string]string) error {
if s.dbType == model.SqliteDBType {
return s.addUpdateCategoryBoard(s.db, userID, categoryID, blockID)
return s.addUpdateCategoryBoard(s.db, userID, boardCategoryMapping)
}
tx, txErr := s.db.BeginTx(context.Background(), nil)
if txErr != nil {
return txErr
}
err := s.addUpdateCategoryBoard(tx, userID, categoryID, blockID)
err := s.addUpdateCategoryBoard(tx, userID, boardCategoryMapping)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "AddUpdateCategoryBoard"))
@ -105,7 +105,26 @@ func (s *SQLStore) CreateBoardsAndBlocksWithAdmin(bab *model.BoardsAndBlocks, us
}
func (s *SQLStore) CreateCategory(category model.Category) error {
return s.createCategory(s.db, category)
if s.dbType == model.SqliteDBType {
return s.createCategory(s.db, category)
}
tx, txErr := s.db.BeginTx(context.Background(), nil)
if txErr != nil {
return txErr
}
err := s.createCategory(tx, category)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "CreateCategory"))
}
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
@ -539,6 +558,11 @@ func (s *SQLStore) GetUserByUsername(username string) (*model.User, error) {
}
func (s *SQLStore) GetUserCategories(userID string, teamID string) ([]model.Category, error) {
return s.getUserCategories(s.db, userID, teamID)
}
func (s *SQLStore) GetUserCategoryBoards(userID string, teamID string) ([]model.CategoryBoards, error) {
return s.getUserCategoryBoards(s.db, userID, teamID)
@ -757,6 +781,16 @@ func (s *SQLStore) RemoveDefaultTemplates(boards []*model.Board) error {
}
func (s *SQLStore) ReorderCategories(userID string, teamID string, newCategoryOrder []string) ([]string, error) {
return s.reorderCategories(s.db, userID, teamID, newCategoryOrder)
}
func (s *SQLStore) ReorderCategoryBoards(categoryID string, newBoardsOrder []string) ([]string, error) {
return s.reorderCategoryBoards(s.db, categoryID, newBoardsOrder)
}
func (s *SQLStore) RunDataRetention(globalRetentionDate int64, batchSize int64) (int64, error) {
if s.dbType == model.SqliteDBType {
return s.runDataRetention(s.db, globalRetentionDate, batchSize)
@ -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)
}

View File

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

View File

@ -6,8 +6,9 @@ package sqlstore
import (
"testing"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/services/store/storetests"
"github.com/mattermost/focalboard/server/model"
"github.com/stretchr/testify/require"
)

View File

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

View File

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

View File

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

View File

@ -9,27 +9,25 @@ import (
"github.com/stretchr/testify/assert"
)
type testFunc func(t *testing.T, store store.Store)
func StoreTestCategoryStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) {
t.Run("CreateCategory", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testGetCreateCategory(t, store)
})
t.Run("UpdateCategory", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testUpdateCategory(t, store)
})
t.Run("DeleteCategory", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testDeleteCategory(t, store)
})
t.Run("GetUserCategories", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
testGetUserCategories(t, store)
})
tests := map[string]testFunc{
"CreateCategory": testGetCreateCategory,
"UpdateCategory": testUpdateCategory,
"DeleteCategory": testDeleteCategory,
"GetUserCategories": testGetUserCategories,
"ReorderCategories": testReorderCategories,
"ReorderCategoriesBoards": testReorderCategoryBoards,
}
for name, f := range tests {
t.Run(name, func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
f(t, store)
})
}
}
func testGetCreateCategory(t *testing.T, store store.Store) {
@ -211,3 +209,129 @@ func testGetUserCategories(t *testing.T, store store.Store) {
assert.NoError(t, err)
assert.Equal(t, 3, len(userCategories))
}
func testReorderCategories(t *testing.T, store store.Store) {
// setup
err := store.CreateCategory(model.Category{
ID: "category_id_1",
Name: "Category 1",
Type: "custom",
UserID: "user_id",
TeamID: "team_id",
})
assert.NoError(t, err)
err = store.CreateCategory(model.Category{
ID: "category_id_2",
Name: "Category 2",
Type: "custom",
UserID: "user_id",
TeamID: "team_id",
})
assert.NoError(t, err)
err = store.CreateCategory(model.Category{
ID: "category_id_3",
Name: "Category 3",
Type: "custom",
UserID: "user_id",
TeamID: "team_id",
})
assert.NoError(t, err)
// verify the current order
categories, err := store.GetUserCategories("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 3, len(categories))
// the categories should show up in reverse insertion order (latest one first)
assert.Equal(t, "category_id_3", categories[0].ID)
assert.Equal(t, "category_id_2", categories[1].ID)
assert.Equal(t, "category_id_1", categories[2].ID)
// re-ordering categories normally
_, err = store.ReorderCategories("user_id", "team_id", []string{
"category_id_2",
"category_id_3",
"category_id_1",
})
assert.NoError(t, err)
// verify the board order
categories, err = store.GetUserCategories("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 3, len(categories))
assert.Equal(t, "category_id_2", categories[0].ID)
assert.Equal(t, "category_id_3", categories[1].ID)
assert.Equal(t, "category_id_1", categories[2].ID)
// lets try specifying a non existing category ID.
// It shouldn't cause any problem
_, err = store.ReorderCategories("user_id", "team_id", []string{
"category_id_1",
"category_id_2",
"category_id_3",
"non-existing-category-id",
})
assert.NoError(t, err)
categories, err = store.GetUserCategories("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 3, len(categories))
assert.Equal(t, "category_id_1", categories[0].ID)
assert.Equal(t, "category_id_2", categories[1].ID)
assert.Equal(t, "category_id_3", categories[2].ID)
}
func testReorderCategoryBoards(t *testing.T, store store.Store) {
// setup
err := store.CreateCategory(model.Category{
ID: "category_id_1",
Name: "Category 1",
Type: "custom",
UserID: "user_id",
TeamID: "team_id",
})
assert.NoError(t, err)
err = store.AddUpdateCategoryBoard("user_id", map[string]string{
"board_id_1": "category_id_1",
"board_id_2": "category_id_1",
"board_id_3": "category_id_1",
"board_id_4": "category_id_1",
})
assert.NoError(t, err)
// verify current order
categoryBoards, err := store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, 4, len(categoryBoards[0].BoardIDs))
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_1")
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_2")
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_3")
assert.Contains(t, categoryBoards[0].BoardIDs, "board_id_4")
// reordering
newOrder, err := store.ReorderCategoryBoards("category_id_1", []string{
"board_id_3",
"board_id_1",
"board_id_2",
"board_id_4",
})
assert.NoError(t, err)
assert.Equal(t, "board_id_3", newOrder[0])
assert.Equal(t, "board_id_1", newOrder[1])
assert.Equal(t, "board_id_2", newOrder[2])
assert.Equal(t, "board_id_4", newOrder[3])
// verify new order
categoryBoards, err = store.GetUserCategoryBoards("user_id", "team_id")
assert.NoError(t, err)
assert.Equal(t, 1, len(categoryBoards))
assert.Equal(t, 4, len(categoryBoards[0].BoardIDs))
assert.Equal(t, "board_id_3", categoryBoards[0].BoardIDs[0])
assert.Equal(t, "board_id_1", categoryBoards[0].BoardIDs[1])
assert.Equal(t, "board_id_2", categoryBoards[0].BoardIDs[2])
assert.Equal(t, "board_id_4", categoryBoards[0].BoardIDs[3])
}

View File

@ -60,14 +60,14 @@ func testGetUserCategoryBoards(t *testing.T, store store.Store) {
// Adding Board 1 and Board 2 to Category 1
// The boards don't need to exists in DB for this test
err = store.AddUpdateCategoryBoard("user_id_1", "category_id_1", "board_1")
err = store.AddUpdateCategoryBoard("user_id_1", map[string]string{"board_1": "category_id_1"})
assert.NoError(t, err)
err = store.AddUpdateCategoryBoard("user_id_1", "category_id_1", "board_2")
err = store.AddUpdateCategoryBoard("user_id_1", map[string]string{"board_2": "category_id_1"})
assert.NoError(t, err)
// Adding Board 3 to Category 2
err = store.AddUpdateCategoryBoard("user_id_1", "category_id_2", "board_3")
err = store.AddUpdateCategoryBoard("user_id_1", map[string]string{"board_3": "category_id_2"})
assert.NoError(t, err)
// we'll leave category 3 empty

View File

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

View File

@ -20,6 +20,8 @@ const (
websocketActionUpdateCategoryBoard = "UPDATE_BOARD_CATEGORY"
websocketActionUpdateSubscription = "UPDATE_SUBSCRIPTION"
websocketActionUpdateCardLimitTimestamp = "UPDATE_CARD_LIMIT_TIMESTAMP"
websocketActionReorderCategories = "REORDER_CATEGORIES"
websocketActionReorderCategoryBoards = "REORDER_CATEGORY_BOARDS"
)
type Store interface {
@ -36,7 +38,9 @@ type Adapter interface {
BroadcastMemberDelete(teamID, boardID, userID string)
BroadcastConfigChange(clientConfig model.ClientConfig)
BroadcastCategoryChange(category model.Category)
BroadcastCategoryBoardChange(teamID, userID string, blockCategory model.BoardCategoryWebsocketData)
BroadcastCategoryBoardChange(teamID, userID string, blockCategory []*model.BoardCategoryWebsocketData)
BroadcastCardLimitTimestampChange(cardLimitTimestamp int64)
BroadcastSubscriptionChange(teamID string, subscription *model.Subscription)
BroadcastCategoryReorder(teamID, userID string, categoryOrder []string)
BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardsOrder []string)
}

View File

@ -6,10 +6,10 @@ import (
// UpdateCategoryMessage is sent on block updates.
type UpdateCategoryMessage struct {
Action string `json:"action"`
TeamID string `json:"teamId"`
Category *model.Category `json:"category,omitempty"`
BoardCategories *model.BoardCategoryWebsocketData `json:"blockCategories,omitempty"`
Action string `json:"action"`
TeamID string `json:"teamId"`
Category *model.Category `json:"category,omitempty"`
BoardCategories []*model.BoardCategoryWebsocketData `json:"blockCategories,omitempty"`
}
// UpdateBlockMsg is sent on block updates.
@ -59,3 +59,16 @@ type WebsocketCommand struct {
ReadToken string `json:"readToken"`
BlockIDs []string `json:"blockIds"`
}
type CategoryReorderMessage struct {
Action string `json:"action"`
CategoryOrder []string `json:"categoryOrder"`
TeamID string `json:"teamId"`
}
type CategoryBoardReorderMessage struct {
Action string `json:"action"`
CategoryID string `json:"CategoryId"`
BoardOrder []string `json:"BoardOrder"`
TeamID string `json:"teamId"`
}

View File

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

View File

@ -496,19 +496,68 @@ func (pa *PluginAdapter) BroadcastCategoryChange(category model.Category) {
pa.sendUserMessageSkipCluster(websocketActionUpdateCategory, payload, category.UserID)
}
func (pa *PluginAdapter) BroadcastCategoryBoardChange(teamID, userID string, boardCategory model.BoardCategoryWebsocketData) {
func (pa *PluginAdapter) BroadcastCategoryReorder(teamID, userID string, categoryOrder []string) {
pa.logger.Debug("BroadcastCategoryReorder",
mlog.String("userID", userID),
mlog.String("teamID", teamID),
)
message := CategoryReorderMessage{
Action: websocketActionReorderCategories,
CategoryOrder: categoryOrder,
TeamID: teamID,
}
payload := utils.StructToMap(message)
go func() {
clusterMessage := &ClusterMessage{
Payload: payload,
UserID: userID,
}
pa.sendMessageToCluster("websocket_message", clusterMessage)
}()
pa.sendUserMessageSkipCluster(message.Action, payload, userID)
}
func (pa *PluginAdapter) BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardsOrder []string) {
pa.logger.Debug("BroadcastCategoryBoardsReorder",
mlog.String("userID", userID),
mlog.String("teamID", teamID),
mlog.String("categoryID", categoryID),
)
message := CategoryBoardReorderMessage{
Action: websocketActionReorderCategoryBoards,
CategoryID: categoryID,
BoardOrder: boardsOrder,
TeamID: teamID,
}
payload := utils.StructToMap(message)
go func() {
clusterMessage := &ClusterMessage{
Payload: payload,
UserID: userID,
}
pa.sendMessageToCluster("websocket_message", clusterMessage)
}()
pa.sendUserMessageSkipCluster(message.Action, payload, userID)
}
func (pa *PluginAdapter) BroadcastCategoryBoardChange(teamID, userID string, boardCategories []*model.BoardCategoryWebsocketData) {
pa.logger.Debug(
"BroadcastCategoryBoardChange",
mlog.String("userID", userID),
mlog.String("teamID", teamID),
mlog.String("categoryID", boardCategory.CategoryID),
mlog.String("blockID", boardCategory.BoardID),
mlog.Int("numEntries", len(boardCategories)),
)
message := UpdateCategoryMessage{
Action: websocketActionUpdateCategoryBoard,
TeamID: teamID,
BoardCategories: &boardCategory,
BoardCategories: boardCategories,
}
payload := utils.StructToMap(message)

View File

@ -589,27 +589,80 @@ func (ws *Server) BroadcastCategoryChange(category model.Category) {
}
}
func (ws *Server) BroadcastCategoryBoardChange(teamID, userID string, boardCategory model.BoardCategoryWebsocketData) {
message := UpdateCategoryMessage{
Action: websocketActionUpdateCategoryBoard,
TeamID: teamID,
BoardCategories: &boardCategory,
func (ws *Server) BroadcastCategoryReorder(teamID, userID string, categoryOrder []string) {
message := CategoryReorderMessage{
Action: websocketActionReorderCategories,
CategoryOrder: categoryOrder,
TeamID: teamID,
}
listeners := ws.getListenersForTeam(teamID)
ws.logger.Debug("listener(s) for teamID",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.String("categoryID", boardCategory.CategoryID),
mlog.String("blockID", boardCategory.BoardID),
)
for _, listener := range listeners {
ws.logger.Debug("Broadcast category order change",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)
if err := listener.WriteJSON(message); err != nil {
ws.logger.Error("broadcast category order change error", mlog.Err(err))
listener.conn.Close()
}
}
}
func (ws *Server) BroadcastCategoryBoardsReorder(teamID, userID, categoryID string, boardOrder []string) {
message := CategoryBoardReorderMessage{
Action: websocketActionReorderCategoryBoards,
CategoryID: categoryID,
BoardOrder: boardOrder,
TeamID: teamID,
}
listeners := ws.getListenersForTeam(teamID)
ws.logger.Debug("listener(s) for teamID",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
)
for _, listener := range listeners {
ws.logger.Debug("Broadcast board category order change",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)
if err := listener.WriteJSON(message); err != nil {
ws.logger.Error("broadcast category order change error", mlog.Err(err))
listener.conn.Close()
}
}
}
func (ws *Server) BroadcastCategoryBoardChange(teamID, userID string, boardCategories []*model.BoardCategoryWebsocketData) {
message := UpdateCategoryMessage{
Action: websocketActionUpdateCategoryBoard,
TeamID: teamID,
BoardCategories: boardCategories,
}
listeners := ws.getListenersForTeam(teamID)
ws.logger.Debug("listener(s) for teamID",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.Int("numEntries", len(boardCategories)),
)
for _, listener := range listeners {
ws.logger.Debug("Broadcast block change",
mlog.Int("listener_count", len(listeners)),
mlog.String("teamID", teamID),
mlog.String("categoryID", boardCategory.CategoryID),
mlog.String("blockID", boardCategory.BoardID),
mlog.Int("numEntries", len(boardCategories)),
mlog.Stringer("remoteAddr", listener.conn.RemoteAddr()),
)

View File

@ -1 +1 @@
v16.3.0
v16.10.0

View File

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

View File

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

View File

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