1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-12-09 08:56:07 +02:00

Merge branch 'main' into private-templates

This commit is contained in:
Harshil Sharma 2022-03-28 13:07:23 +05:30
commit 98ca5b5691
85 changed files with 18538 additions and 21895 deletions

View File

@ -1,8 +1,8 @@
.PHONY: run
run:
go run ./main.go
go run -tags json1 ./main.go
build:
mkdir -p bin
go build -o bin/focalboard-app
go build -tags json1 -o bin/focalboard-app

View File

@ -6,7 +6,7 @@ replace github.com/mattermost/focalboard/server => ../server
require (
github.com/google/uuid v1.3.0
github.com/mattermost/focalboard/server v0.0.0-20210422230105-f5ae0b265a8d
github.com/mattermost/mattermost-server/v6 v6.0.0-20210913141218-bb659d03fde0
github.com/webview/webview v0.0.0-20200724072439-e0c01595b361
github.com/mattermost/focalboard/server v0.0.0-20220325164658-33557093b00d
github.com/mattermost/mattermost-server/v6 v6.5.0
github.com/webview/webview v0.0.0-20220314230258-a2b7746141c3
)

File diff suppressed because it is too large Load Diff

View File

@ -67,7 +67,6 @@ linters:
- unconvert
- unused
- whitespace
- gocyclo
issues:
exclude-rules:

View File

@ -5,8 +5,8 @@ go 1.16
replace github.com/mattermost/focalboard/server => ../server
require (
github.com/mattermost/focalboard/server v0.0.0-20210525112228-f43e4028dbdc
github.com/mattermost/mattermost-plugin-api v0.0.21
github.com/mattermost/mattermost-server/v6 v6.0.0-20211022142730-a6cca93ba3c3
github.com/stretchr/testify v1.7.0
github.com/mattermost/focalboard/server v0.0.0-20220325164658-33557093b00d
github.com/mattermost/mattermost-plugin-api v0.0.27
github.com/mattermost/mattermost-server/v6 v6.5.0
github.com/stretchr/testify v1.7.1
)

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@ import (
"github.com/mattermost/focalboard/server/ws"
pluginapi "github.com/mattermost/mattermost-plugin-api"
apierrors "github.com/mattermost/mattermost-plugin-api/errors"
"github.com/mattermost/mattermost-server/v6/model"
@ -113,5 +114,5 @@ func (da *pluginAPIAdapter) GetChannelMember(channelID string, userID string) (*
}
func (da *pluginAPIAdapter) IsErrNotFound(err error) bool {
return errors.Is(err, pluginapi.ErrNotFound)
return errors.Is(err, apierrors.ErrNotFound)
}

View File

@ -99,6 +99,7 @@ func (p *Plugin) OnActivate() error {
NewMutexFn: func(name string) (*cluster.Mutex, error) {
return cluster.NewMutex(p.API, name)
},
PluginAPI: &p.API,
}
var db store.Store

File diff suppressed because it is too large Load Diff

View File

@ -14,71 +14,71 @@
"check-types": "tsc"
},
"devDependencies": {
"@babel/cli": "7.16.8",
"@babel/core": "7.16.12",
"@babel/plugin-proposal-class-properties": "7.13.0",
"@babel/plugin-proposal-object-rest-spread": "7.14.2",
"@babel/plugin-proposal-optional-chaining": "7.14.2",
"@babel/cli": "7.17.6",
"@babel/core": "7.17.8",
"@babel/plugin-proposal-class-properties": "7.16.7",
"@babel/plugin-proposal-object-rest-spread": "7.17.3",
"@babel/plugin-proposal-optional-chaining": "7.16.7",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/polyfill": "7.10.4",
"@babel/preset-env": "7.14.2",
"@babel/preset-react": "7.13.13",
"@babel/preset-typescript": "7.13.0",
"@babel/runtime": "7.14.0",
"@formatjs/ts-transformer": "3.4.2",
"@types/enzyme": "3.10.8",
"@types/jest": "26.0.23",
"@types/node": "15.6.1",
"@types/react": "17.0.6",
"@types/react-dom": "17.0.5",
"@babel/preset-env": "7.16.11",
"@babel/preset-react": "7.16.7",
"@babel/preset-typescript": "7.16.7",
"@babel/runtime": "7.17.8",
"@formatjs/ts-transformer": "3.9.2",
"@types/enzyme": "3.10.11",
"@types/jest": "27.4.1",
"@types/node": "17.0.23",
"@types/react": "17.0.42",
"@types/react-dom": "17.0.14",
"@types/react-intl": "3.0.0",
"@types/react-redux": "7.1.16",
"@types/react-router-dom": "5.1.7",
"@types/react-transition-group": "4.4.1",
"@typescript-eslint/eslint-plugin": "4.25.0",
"@typescript-eslint/parser": "4.25.0",
"@types/react-redux": "7.1.23",
"@types/react-router-dom": "5.3.3",
"@types/react-transition-group": "4.4.4",
"@typescript-eslint/eslint-plugin": "5.16.0",
"@typescript-eslint/parser": "5.16.0",
"babel-eslint": "10.1.0",
"babel-jest": "27.0.1",
"babel-loader": "8.2.2",
"babel-plugin-typescript-to-proptypes": "1.4.2",
"css-loader": "5.2.6",
"eslint": "7.27.0",
"eslint-import-resolver-webpack": "0.13.1",
"babel-jest": "27.5.1",
"babel-loader": "8.2.4",
"babel-plugin-typescript-to-proptypes": "2.0.0",
"css-loader": "6.7.1",
"eslint": "8.11.0",
"eslint-import-resolver-webpack": "0.13.2",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-cypress": "2.11.2",
"eslint-plugin-cypress": "2.12.1",
"eslint-plugin-header": "3.1.1",
"eslint-plugin-import": "2.23.3",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-jquery": "1.5.1",
"eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#070ce792d105482ffb2b27cfc0b7e78b3d20acee",
"eslint-plugin-no-only-tests": "2.4.0",
"eslint-plugin-react": "7.23.2",
"eslint-plugin-react-hooks": "4.2.0",
"eslint-plugin-no-only-tests": "2.6.0",
"eslint-plugin-react": "7.29.4",
"eslint-plugin-react-hooks": "4.3.0",
"file-loader": "6.2.0",
"identity-obj-proxy": "3.0.0",
"image-webpack-loader": "8.1.0",
"imagemin-gifsicle": "^7.0.0",
"imagemin-mozjpeg": "^9.0.0",
"imagemin-mozjpeg": "^10.0.0",
"imagemin-optipng": "^8.0.0",
"imagemin-pngquant": "^9.0.2",
"imagemin-svgo": "^8.0.0",
"imagemin-svgo": "^10.0.1",
"imagemin-webp": "7.0.0",
"jest": "27.0.1",
"jest": "27.5.1",
"jest-canvas-mock": "2.3.1",
"jest-junit": "12.0.0",
"sass": "1.25.0",
"sass-loader": "11.1.1",
"style-loader": "2.0.0",
"ts-loader": "9.2.3",
"typescript": "4.2.4",
"webpack": "5.37.1",
"webpack-cli": "4.7.0"
"jest-junit": "13.0.0",
"sass": "1.49.9",
"sass-loader": "12.6.0",
"style-loader": "3.3.1",
"ts-loader": "9.2.8",
"typescript": "4.6.2",
"webpack": "5.70.0",
"webpack-cli": "4.9.2"
},
"dependencies": {
"core-js": "3.12.1",
"core-js": "3.21.1",
"glob-parent": "6.0.2",
"marked": ">=4.0.12",
"mattermost-redux": "5.33.1",
"react-intl": "^5.13.5",
"react-intl": "^5.24.7",
"react-router-dom": "5.2.0",
"trim-newlines": "4.0.2"
},

View File

@ -112,20 +112,10 @@ module.exports = {
},
{
test: /\.(png|eot|tiff|svg|woff2|woff|ttf|jpg|gif)$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'static',
publicPath: '/static/',
},
},
{
loader: 'image-webpack-loader',
options: {},
},
],
type: 'asset/resource',
generator: {
filename: 'static/[name].[ext]',
}
},
],
},

View File

@ -65,4 +65,3 @@ linters:
- unconvert
- unused
- whitespace
- gocyclo

View File

@ -82,7 +82,7 @@ func (a *API) RegisterRoutes(r *mux.Router) {
apiv1.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handlePatchBoard)).Methods("PATCH")
apiv1.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handleDeleteBoard)).Methods("DELETE")
apiv1.HandleFunc("/boards/{boardID}/duplicate", a.sessionRequired(a.handleDuplicateBoard)).Methods("POST")
apiv1.HandleFunc("/boards/{boardID}/blocks", a.sessionRequired(a.handleGetBlocks)).Methods("GET")
apiv1.HandleFunc("/boards/{boardID}/blocks", a.attachSession(a.handleGetBlocks, false)).Methods("GET")
apiv1.HandleFunc("/boards/{boardID}/blocks", a.sessionRequired(a.handlePostBlocks)).Methods("POST")
apiv1.HandleFunc("/boards/{boardID}/blocks", a.sessionRequired(a.handlePatchBlocks)).Methods("PATCH")
apiv1.HandleFunc("/boards/{boardID}/blocks/{blockID}", a.sessionRequired(a.handleDeleteBlock)).Methods("DELETE")
@ -289,15 +289,17 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
return
}
if board.IsTemplate {
if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board template"})
return
}
} else {
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"})
return
if !a.hasValidReadTokenForBoard(r, boardID) {
if board.IsTemplate {
if !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board template"})
return
}
} else {
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"})
return
}
}
}
@ -3552,7 +3554,6 @@ func (a *API) handleCreateBoardsAndBlocks(w http.ResponseWriter, r *http.Request
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to create private boards"})
return
}
auditRec := a.makeAuditRecord(r, "createBoardsAndBlocks", audit.Fail)
defer a.audit.LogRecord(audit.LevelModify, auditRec)
auditRec.AddMeta("teamID", teamID)

View File

@ -3,30 +3,40 @@ module github.com/mattermost/focalboard/server
go 1.16
require (
github.com/Masterminds/squirrel v1.5.0
github.com/Masterminds/squirrel v1.5.2
github.com/go-sql-driver/mysql v1.6.0
github.com/golang/mock v1.5.0
github.com/golang/mock v1.6.0
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/go-hclog v1.2.0 // indirect
github.com/klauspost/compress v1.15.1 // indirect
github.com/klauspost/cpuid/v2 v2.0.12 // indirect
github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94
github.com/lib/pq v1.10.2
github.com/magiconair/properties v1.8.5 // indirect
github.com/mattermost/mattermost-plugin-api v0.0.21
github.com/mattermost/mattermost-server/v6 v6.0.0-20210913141218-bb659d03fde0
github.com/mattermost/morph v0.0.0-20220222074146-cff3f12ff131
github.com/lib/pq v1.10.4
github.com/magiconair/properties v1.8.6 // indirect
github.com/mattermost/mattermost-plugin-api v0.0.27
github.com/mattermost/mattermost-server/v6 v6.5.0
github.com/mattermost/morph v0.0.0-20220324143723-e4896385ec60
github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/mitchellh/mapstructure v1.4.1 // indirect
github.com/minio/minio-go/v7 v7.0.23 // indirect
github.com/oklog/run v1.1.0
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.11.0
github.com/rudderlabs/analytics-go v3.3.1+incompatible
github.com/sergi/go-diff v1.0.0
github.com/spf13/afero v1.6.0 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/viper v1.7.1
github.com/stretchr/testify v1.7.0
github.com/prometheus/client_golang v1.12.1
github.com/rs/xid v1.4.0 // indirect
github.com/rudderlabs/analytics-go v3.3.2+incompatible
github.com/sergi/go-diff v1.2.0
github.com/spf13/afero v1.8.2 // indirect
github.com/spf13/viper v1.10.1
github.com/stretchr/testify v1.7.1
github.com/wiggin77/merror v1.0.3
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
github.com/yuin/goldmark v1.4.11 // indirect
golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064
golang.org/x/net v0.0.0-20220325170049-de3da57026de // indirect
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 // indirect
golang.org/x/tools v0.1.10 // indirect
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/ini.v1 v1.66.4 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/sqlite v1.15.3 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -186,6 +186,21 @@ func (mr *MockAPIMockRecorder) CreatePost(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePost", reflect.TypeOf((*MockAPI)(nil).CreatePost), arg0)
}
// CreateSession mocks base method.
func (m *MockAPI) CreateSession(arg0 *model.Session) (*model.Session, *model.AppError) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateSession", arg0)
ret0, _ := ret[0].(*model.Session)
ret1, _ := ret[1].(*model.AppError)
return ret0, ret1
}
// CreateSession indicates an expected call of CreateSession.
func (mr *MockAPIMockRecorder) CreateSession(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSession", reflect.TypeOf((*MockAPI)(nil).CreateSession), arg0)
}
// CreateTeam mocks base method.
func (m *MockAPI) CreateTeam(arg0 *model.Team) (*model.Team, *model.AppError) {
m.ctrl.T.Helper()
@ -457,6 +472,20 @@ func (mr *MockAPIMockRecorder) ExecuteSlashCommand(arg0 interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteSlashCommand", reflect.TypeOf((*MockAPI)(nil).ExecuteSlashCommand), arg0)
}
// ExtendSessionExpiry mocks base method.
func (m *MockAPI) ExtendSessionExpiry(arg0 string, arg1 int64) *model.AppError {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ExtendSessionExpiry", arg0, arg1)
ret0, _ := ret[0].(*model.AppError)
return ret0
}
// ExtendSessionExpiry indicates an expected call of ExtendSessionExpiry.
func (mr *MockAPIMockRecorder) ExtendSessionExpiry(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtendSessionExpiry", reflect.TypeOf((*MockAPI)(nil).ExtendSessionExpiry), arg0, arg1)
}
// GetBot mocks base method.
func (m *MockAPI) GetBot(arg0 string, arg1 bool) (*model.Bot, *model.AppError) {
m.ctrl.T.Helper()
@ -1573,6 +1602,20 @@ func (mr *MockAPIMockRecorder) InstallPlugin(arg0, arg1 interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallPlugin", reflect.TypeOf((*MockAPI)(nil).InstallPlugin), arg0, arg1)
}
// IsEnterpriseReady mocks base method.
func (m *MockAPI) IsEnterpriseReady() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsEnterpriseReady")
ret0, _ := ret[0].(bool)
return ret0
}
// IsEnterpriseReady indicates an expected call of IsEnterpriseReady.
func (mr *MockAPIMockRecorder) IsEnterpriseReady() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsEnterpriseReady", reflect.TypeOf((*MockAPI)(nil).IsEnterpriseReady))
}
// KVCompareAndDelete mocks base method.
func (m *MockAPI) KVCompareAndDelete(arg0 string, arg1 []byte) (bool, *model.AppError) {
m.ctrl.T.Helper()
@ -2014,6 +2057,20 @@ func (mr *MockAPIMockRecorder) RemoveTeamIcon(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveTeamIcon", reflect.TypeOf((*MockAPI)(nil).RemoveTeamIcon), arg0)
}
// RemoveUserCustomStatus mocks base method.
func (m *MockAPI) RemoveUserCustomStatus(arg0 string) *model.AppError {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RemoveUserCustomStatus", arg0)
ret0, _ := ret[0].(*model.AppError)
return ret0
}
// RemoveUserCustomStatus indicates an expected call of RemoveUserCustomStatus.
func (mr *MockAPIMockRecorder) RemoveUserCustomStatus(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveUserCustomStatus", reflect.TypeOf((*MockAPI)(nil).RemoveUserCustomStatus), arg0)
}
// RequestTrialLicense mocks base method.
func (m *MockAPI) RequestTrialLicense(arg0 string, arg1 int, arg2, arg3 bool) *model.AppError {
m.ctrl.T.Helper()
@ -2028,6 +2085,20 @@ func (mr *MockAPIMockRecorder) RequestTrialLicense(arg0, arg1, arg2, arg3 interf
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestTrialLicense", reflect.TypeOf((*MockAPI)(nil).RequestTrialLicense), arg0, arg1, arg2, arg3)
}
// RevokeSession mocks base method.
func (m *MockAPI) RevokeSession(arg0 string) *model.AppError {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RevokeSession", arg0)
ret0, _ := ret[0].(*model.AppError)
return ret0
}
// RevokeSession indicates an expected call of RevokeSession.
func (mr *MockAPIMockRecorder) RevokeSession(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeSession", reflect.TypeOf((*MockAPI)(nil).RevokeSession), arg0)
}
// RevokeUserAccessToken mocks base method.
func (m *MockAPI) RevokeUserAccessToken(arg0 string) *model.AppError {
m.ctrl.T.Helper()
@ -2042,6 +2113,20 @@ func (mr *MockAPIMockRecorder) RevokeUserAccessToken(arg0 interface{}) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeUserAccessToken", reflect.TypeOf((*MockAPI)(nil).RevokeUserAccessToken), arg0)
}
// RolesGrantPermission mocks base method.
func (m *MockAPI) RolesGrantPermission(arg0 []string, arg1 string) bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RolesGrantPermission", arg0, arg1)
ret0, _ := ret[0].(bool)
return ret0
}
// RolesGrantPermission indicates an expected call of RolesGrantPermission.
func (mr *MockAPIMockRecorder) RolesGrantPermission(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RolesGrantPermission", reflect.TypeOf((*MockAPI)(nil).RolesGrantPermission), arg0, arg1)
}
// SaveConfig mocks base method.
func (m *MockAPI) SaveConfig(arg0 *model.Config) *model.AppError {
m.ctrl.T.Helper()
@ -2437,6 +2522,20 @@ func (mr *MockAPIMockRecorder) UpdateUserActive(arg0, arg1 interface{}) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserActive", reflect.TypeOf((*MockAPI)(nil).UpdateUserActive), arg0, arg1)
}
// UpdateUserCustomStatus mocks base method.
func (m *MockAPI) UpdateUserCustomStatus(arg0 string, arg1 *model.CustomStatus) *model.AppError {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserCustomStatus", arg0, arg1)
ret0, _ := ret[0].(*model.AppError)
return ret0
}
// UpdateUserCustomStatus indicates an expected call of UpdateUserCustomStatus.
func (mr *MockAPIMockRecorder) UpdateUserCustomStatus(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserCustomStatus", reflect.TypeOf((*MockAPI)(nil).UpdateUserCustomStatus), arg0, arg1)
}
// UpdateUserStatus mocks base method.
func (m *MockAPI) UpdateUserStatus(arg0, arg1 string) (*model.Status, *model.AppError) {
m.ctrl.T.Helper()

View File

@ -213,14 +213,34 @@ func (s *SQLStore) getBoardsForUserAndTeam(db sq.BaseRunner, userID, teamID stri
func (s *SQLStore) insertBoard(db sq.BaseRunner, board *model.Board, userID string) (*model.Board, error) {
propertiesBytes, err := json.Marshal(board.Properties)
if err != nil {
s.logger.Error(
"failed to marshal board.Properties",
mlog.String("board_id", board.ID),
mlog.String("board.Properties", fmt.Sprintf("%v", board.Properties)),
mlog.Err(err),
)
return nil, err
}
cardPropertiesBytes, err := json.Marshal(board.CardProperties)
if err != nil {
s.logger.Error(
"failed to marshal board.CardProperties",
mlog.String("board_id", board.ID),
mlog.String("board.CardProperties", fmt.Sprintf("%v", board.CardProperties)),
mlog.Err(err),
)
return nil, err
}
columnCalculationsBytes, err := json.Marshal(board.ColumnCalculations)
if err != nil {
s.logger.Error(
"failed to marshal board.ColumnCalculations",
mlog.String("board_id", board.ID),
mlog.String("board.ColumnCalculations", fmt.Sprintf("%v", board.ColumnCalculations)),
mlog.Err(err),
)
return nil, err
}
@ -290,6 +310,7 @@ func (s *SQLStore) insertBoard(db sq.BaseRunner, board *model.Board, userID stri
// writing board history
query := insertQuery.SetMap(insertQueryValues).Into(s.tablePrefix + "boards_history")
if _, err := query.Exec(); err != nil {
s.logger.Error("failed to insert board history", mlog.String("board_id", board.ID), mlog.Err(err))
return nil, err
}
@ -395,7 +416,7 @@ func (s *SQLStore) saveMember(db sq.BaseRunner, bm *model.BoardMember) (*model.B
} else {
query = query.Suffix(
`ON CONFLICT (board_id, user_id)
DO UPDATE SET scheme_admin = EXCLUDED.scheme_admin, scheme_editor = EXCLUDED.scheme_editor,
DO UPDATE SET scheme_admin = EXCLUDED.scheme_admin, scheme_editor = EXCLUDED.scheme_editor,
scheme_commenter = EXCLUDED.scheme_commenter, scheme_viewer = EXCLUDED.scheme_viewer`,
)
}

View File

@ -5,8 +5,13 @@ import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"github.com/mattermost/focalboard/server/utils"
"path/filepath"
"strconv"
"text/template"
"github.com/mattermost/morph/models"
@ -18,7 +23,7 @@ import (
mysql "github.com/mattermost/morph/drivers/mysql"
postgres "github.com/mattermost/morph/drivers/postgres"
sqlite "github.com/mattermost/morph/drivers/sqlite"
mbindata "github.com/mattermost/morph/sources/go_bindata"
embedded "github.com/mattermost/morph/sources/embedded"
mysqldriver "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq" // postgres driver
@ -33,11 +38,16 @@ import (
var assets embed.FS
const (
uniqueIDsMigrationRequiredVersion = 14
uniqueIDsMigrationRequiredVersion = 14
teamsAndBoardsMigrationRequiredVersion = 17
teamLessBoardsMigrationKey = "TeamLessBoardsMigrationComplete"
tempSchemaMigrationTableName = "temp_schema_migration"
)
var errChannelCreatorNotInTeam = errors.New("channel creator not found in user teams")
func appendMultipleStatementsFlag(connectionString string) (string, error) {
config, err := mysqldriver.ParseDSN(connectionString)
if err != nil {
@ -134,7 +144,7 @@ func (s *SQLStore) Migrate() error {
"plugin": s.isPlugin,
}
migrationAssets := &mbindata.AssetSource{
migrationAssets := &embedded.AssetSource{
Names: assetNamesForDriver,
AssetFunc: func(name string) ([]byte, error) {
asset, mErr := assets.ReadFile(filepath.Join("migrations", name))
@ -157,11 +167,10 @@ func (s *SQLStore) Migrate() error {
},
}
src, err := mbindata.WithInstance(migrationAssets)
src, err := embedded.WithInstance(migrationAssets)
if err != nil {
return err
}
defer src.Close()
opts := []morph.EngineOption{
morph.WithLock("mm-lock-key"),
@ -220,6 +229,23 @@ func (s *SQLStore) Migrate() error {
}
if err := s.deleteOldSchemaMigrationTable(); err != nil {
if s.isPlugin {
mutex.Unlock()
}
return err
}
if err := ensureMigrationsAppliedUpToVersion(engine, driver, teamsAndBoardsMigrationRequiredVersion); err != nil {
if s.isPlugin {
mutex.Unlock()
}
return err
}
if err := s.migrateTeamLessBoards(); err != nil {
if s.isPlugin {
mutex.Unlock()
}
return err
}
@ -442,6 +468,192 @@ func (s *SQLStore) deleteOldSchemaMigrationTable() error {
return nil
}
// We no longer support boards existing in DMs and private
// group messages. This function migrates all boards
// belonging to a DM to the best possible team.
func (s *SQLStore) migrateTeamLessBoards() error {
if !s.isPlugin {
return nil
}
setting, err := s.GetSystemSetting(teamLessBoardsMigrationKey)
if err != nil {
return fmt.Errorf("cannot get teamless boards migration state: %w", err)
}
// If the migration is already completed, do not run it again.
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
return nil
}
boards, err := s.getDMBoards(s.db)
if err != nil {
return err
}
s.logger.Info(fmt.Sprintf("Migrating %d teamless boards to a team", len(boards)))
// cache for best suitable team for a DM. Since a DM can
// contain multiple boards, caching this avoids
// duplicate queries for the same DM.
channelToTeamCache := map[string]string{}
tx, err := s.db.BeginTx(context.Background(), nil)
if err != nil {
s.logger.Error("error starting transaction in migrateTeamLessBoards", mlog.Err(err))
return err
}
for i := range boards {
// check the cache first
teamID, ok := channelToTeamCache[boards[i].ChannelID]
// query DB if entry not found in cache
if !ok {
teamID, err = s.getBestTeamForBoard(s.db, boards[i])
if err != nil {
// don't let one board's error spoil
// the mood for others
continue
}
}
channelToTeamCache[boards[i].ChannelID] = teamID
boards[i].TeamID = teamID
query := s.getQueryBuilder(tx).
Update(s.tablePrefix+"boards").
Set("team_id", teamID).
Set("type", model.BoardTypePrivate).
Where(sq.Eq{"id": boards[i].ID})
if _, err := query.Exec(); err != nil {
s.logger.Error("failed to set team id for board", mlog.String("board_id", boards[i].ID), mlog.String("team_id", teamID), mlog.Err(err))
return err
}
}
if err := s.setSystemSetting(tx, teamLessBoardsMigrationKey, strconv.FormatBool(true)); err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "migrateTeamLessBoards"))
}
return fmt.Errorf("cannot mark migration as completed: %w", err)
}
if err := tx.Commit(); err != nil {
s.logger.Error("failed to commit migrateTeamLessBoards transaction", mlog.Err(err))
return err
}
return nil
}
func (s *SQLStore) getDMBoards(tx sq.BaseRunner) ([]*model.Board, error) {
conditions := sq.And{
sq.Eq{"team_id": ""},
sq.Or{
sq.Eq{"type": "D"},
sq.Eq{"type": "G"},
},
}
return s.getBoardsByCondition(tx, conditions)
}
// The destination is selected as the first team where all members
// of the DM are a part of. If no such team exists,
// we use the first team to which DM creator belongs to.
func (s *SQLStore) getBestTeamForBoard(tx sq.BaseRunner, board *model.Board) (string, error) {
userTeams, err := s.getBoardUserTeams(tx, board)
if err != nil {
return "", err
}
teams := [][]interface{}{}
for _, userTeam := range userTeams {
userTeamInterfaces := make([]interface{}, len(userTeam))
for i := range userTeam {
userTeamInterfaces[i] = userTeam[i]
}
teams = append(teams, userTeamInterfaces)
}
commonTeams := utils.Intersection(teams...)
var teamID string
if len(commonTeams) > 0 {
teamID = commonTeams[0].(string)
} else {
// no common teams found. Let's try finding the best suitable team
if board.Type == "D" {
// get DM's creator and pick one of their team
channel, appErr := (*s.pluginAPI).GetChannel(board.ChannelID)
if appErr != nil {
s.logger.Error("failed to fetch DM channel for board", mlog.String("board_id", board.ID), mlog.String("channel_id", board.ChannelID), mlog.Err(appErr))
return "", appErr
}
if _, ok := userTeams[channel.CreatorId]; !ok {
err := fmt.Errorf("%w board_id: %s, channel_id: %s, creator_id: %s", errChannelCreatorNotInTeam, board.ID, board.ChannelID, channel.CreatorId)
s.logger.Error(err.Error())
return "", err
}
teamID = userTeams[channel.CreatorId][0]
} else if board.Type == "G" {
// pick the team that has the most users as members
teamFrequency := map[string]int{}
highestFrequencyTeam := ""
highestFrequencyTeamFrequency := -1
for _, teams := range userTeams {
for _, teamID := range teams {
teamFrequency[teamID]++
if teamFrequency[teamID] > highestFrequencyTeamFrequency {
highestFrequencyTeamFrequency = teamFrequency[teamID]
highestFrequencyTeam = teamID
}
}
}
teamID = highestFrequencyTeam
}
}
return teamID, nil
}
func (s *SQLStore) getBoardUserTeams(tx sq.BaseRunner, board *model.Board) (map[string][]string, error) {
query := s.getQueryBuilder(tx).
Select("teammembers.userid", "teammembers.teamid").
From("channelmembers").
Join("teammembers ON channelmembers.userid = teammembers.userid").
Where(sq.Eq{"channelid": board.ChannelID})
rows, err := query.Query()
if err != nil {
s.logger.Error("failed to fetch user teams for board", mlog.String("boardID", board.ID), mlog.String("channelID", board.ChannelID), mlog.Err(err))
return nil, err
}
defer rows.Close()
userTeams := map[string][]string{}
for rows.Next() {
var userID, teamID string
err := rows.Scan(&userID, &teamID)
if err != nil {
s.logger.Error("getBoardUserTeams failed to scan SQL query result", mlog.String("boardID", board.ID), mlog.String("channelID", board.ChannelID), mlog.Err(err))
return nil, err
}
userTeams[userID] = append(userTeams[userID], teamID)
}
return userTeams, nil
}
func ensureMigrationsAppliedUpToVersion(engine *morph.Morph, driver drivers.Driver, version int) error {
applied, err := driver.AppliedMigrations()
if err != nil {

View File

@ -108,8 +108,10 @@ CREATE TABLE {{.prefix}}boards_history (
{{if .postgres}}
INSERT INTO {{.prefix}}boards (
SELECT B.id, B.insert_at, C.TeamId, B.channel_id, B.created_by, B.modified_by, C.type, B.title, (B.fields->>'description')::text,
B.fields->>'icon', (B.fields->'showDescription')::text::boolean, (B.fields->'isTemplate')::text::boolean,
COALESCE((B.fields->'templateVer')::text, '0')::int,
B.fields->>'icon',
COALESCE((fields->'showDescription')::text::boolean, false),
COALESCE((fields->'isTemplate')::text::boolean, false),
COALESCE((B.fields->'templateVer')::text::int, 0),
'{}', B.fields->'cardProperties', B.fields->'columnCalculations', B.create_at,
B.update_at, B.delete_at
FROM {{.prefix}}blocks AS B
@ -118,8 +120,10 @@ CREATE TABLE {{.prefix}}boards_history (
);
INSERT INTO {{.prefix}}boards_history (
SELECT B.id, B.insert_at, C.TeamId, B.channel_id, B.created_by, B.modified_by, C.type, B.title, (B.fields->>'description')::text,
B.fields->>'icon', (B.fields->'showDescription')::text::boolean, (B.fields->'isTemplate')::text::boolean,
COALESCE((B.fields->'templateVer')::text, '0')::int,
B.fields->>'icon',
COALESCE((fields->'showDescription')::text::boolean, false),
COALESCE((fields->'isTemplate')::text::boolean, false),
COALESCE((B.fields->'templateVer')::text::int, 0),
'{}', B.fields->'cardProperties', B.fields->'columnCalculations', B.create_at,
B.update_at, B.delete_at
FROM {{.prefix}}blocks_history AS B
@ -157,8 +161,10 @@ CREATE TABLE {{.prefix}}boards_history (
{{if .postgres}}
INSERT INTO {{.prefix}}boards (
SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O', title, (fields->>'description')::text,
B.fields->>'icon', (fields->'showDescription')::text::boolean, (fields->'isTemplate')::text::boolean,
(B.fields->'templateVer')::text::int,
B.fields->>'icon',
COALESCE((fields->'showDescription')::text::boolean, false),
COALESCE((fields->'isTemplate')::text::boolean, false),
COALESCE((B.fields->'templateVer')::text::int, 0),
'{}', fields->'cardProperties', fields->'columnCalculations', create_at,
update_at, delete_at
FROM {{.prefix}}blocks AS B
@ -166,8 +172,10 @@ CREATE TABLE {{.prefix}}boards_history (
);
INSERT INTO {{.prefix}}boards_history (
SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O', title, (fields->>'description')::text,
B.fields->>'icon', (fields->'showDescription')::text::boolean, (fields->'isTemplate')::text::boolean,
(B.fields->'templateVer')::text::int,
B.fields->>'icon',
COALESCE((fields->'showDescription')::text::boolean, false),
COALESCE((fields->'isTemplate')::text::boolean, false),
COALESCE((B.fields->'templateVer')::text::int, 0),
'{}', fields->'cardProperties', fields->'columnCalculations', create_at,
update_at, delete_at
FROM {{.prefix}}blocks_history AS B
@ -177,8 +185,10 @@ CREATE TABLE {{.prefix}}boards_history (
{{if .mysql}}
INSERT INTO {{.prefix}}boards (
SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O', title, JSON_UNQUOTE(JSON_EXTRACT(fields,'$.description')),
JSON_UNQUOTE(JSON_EXTRACT(fields,'$.icon')), fields->'$.showDescription', fields->'$.isTemplate',
B.fields->'$.templateVer',
JSON_UNQUOTE(JSON_EXTRACT(fields,'$.icon')),
COALESCE(B.fields->'$.showDescription', 'false') = 'true',
COALESCE(JSON_EXTRACT(B.fields, '$.isTemplate'), 'false') = 'true',
COALESCE(B.fields->'$.templateVer', 0),
'{}', fields->'$.cardProperties', fields->'$.columnCalculations', create_at,
update_at, delete_at
FROM {{.prefix}}blocks AS B
@ -186,8 +196,10 @@ CREATE TABLE {{.prefix}}boards_history (
);
INSERT INTO {{.prefix}}boards_history (
SELECT id, insert_at, '0', channel_id, created_by, modified_by, 'O', title, JSON_UNQUOTE(JSON_EXTRACT(fields,'$.description')),
JSON_UNQUOTE(JSON_EXTRACT(fields,'$.icon')), fields->'$.showDescription', fields->'$.isTemplate',
B.fields->'$.templateVer',
JSON_UNQUOTE(JSON_EXTRACT(fields,'$.icon')),
COALESCE(B.fields->'$.showDescription', 'false') = 'true',
COALESCE(JSON_EXTRACT(B.fields, '$.isTemplate'), 'false') = 'true',
COALESCE(B.fields->'$.templateVer', 0),
'{}', fields->'$.cardProperties', fields->'$.columnCalculations', create_at,
update_at, delete_at
FROM {{.prefix}}blocks_history AS B
@ -366,7 +378,7 @@ CREATE INDEX idx_boardmembers_user_id ON {{.prefix}}board_members(user_id);
INSERT INTO {{.prefix}}board_members (
SELECT B.Id, CM.UserId, CM.Roles, (CM.UserId=B.created_by) OR CM.SchemeAdmin, CM.SchemeUser, FALSE, CM.SchemeGuest
FROM {{.prefix}}boards AS B
INNER JOIN ChannelMembers as CM ON CM.ChannelId=B.channel_id;
INNER JOIN ChannelMembers as CM ON CM.ChannelId=B.channel_id
);
{{else}}
{{- /* if we're in personal server or desktop, create memberships for everyone */ -}}

View File

@ -4,6 +4,8 @@ import (
"database/sql"
"fmt"
"github.com/mattermost/mattermost-server/v6/plugin"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
@ -15,6 +17,7 @@ type Params struct {
DB *sql.DB
IsPlugin bool
NewMutexFn MutexFactory
PluginAPI *plugin.API
}
func (p Params) CheckValid() error {

View File

@ -3,6 +3,8 @@ package sqlstore
import (
"database/sql"
"github.com/mattermost/mattermost-server/v6/plugin"
sq "github.com/Masterminds/squirrel"
"github.com/mattermost/focalboard/server/model"
@ -20,6 +22,7 @@ type SQLStore struct {
isPlugin bool
logger *mlog.Logger
NewMutexFn MutexFactory
pluginAPI *plugin.API
}
// MutexFactory is used by the store in plugin mode to generate
@ -42,6 +45,7 @@ func New(params Params) (*SQLStore, error) {
logger: params.Logger,
isPlugin: params.IsPlugin,
NewMutexFn: params.NewMutexFn,
pluginAPI: params.PluginAPI,
}
err := store.Migrate()

View File

@ -2,6 +2,7 @@ package utils
import (
"encoding/json"
"reflect"
"time"
mm_model "github.com/mattermost/mattermost-server/v6/model"
@ -54,3 +55,43 @@ func StructToMap(v interface{}) (m map[string]interface{}) {
_ = json.Unmarshal(b, &m)
return
}
func intersection(a []interface{}, b []interface{}) []interface{} {
set := make([]interface{}, 0)
hash := make(map[interface{}]bool)
av := reflect.ValueOf(a)
bv := reflect.ValueOf(b)
for i := 0; i < av.Len(); i++ {
el := av.Index(i).Interface()
hash[el] = true
}
for i := 0; i < bv.Len(); i++ {
el := bv.Index(i).Interface()
if _, found := hash[el]; found {
set = append(set, el)
}
}
return set
}
func Intersection(x ...[]interface{}) []interface{} {
if len(x) == 0 {
return nil
}
if len(x) == 1 {
return x[0]
}
result := x[0]
i := 1
for i < len(x) {
result = intersection(result, x[i])
i++
}
return result
}

View File

@ -186,6 +186,21 @@ func (mr *MockAPIMockRecorder) CreatePost(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePost", reflect.TypeOf((*MockAPI)(nil).CreatePost), arg0)
}
// CreateSession mocks base method.
func (m *MockAPI) CreateSession(arg0 *model.Session) (*model.Session, *model.AppError) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateSession", arg0)
ret0, _ := ret[0].(*model.Session)
ret1, _ := ret[1].(*model.AppError)
return ret0, ret1
}
// CreateSession indicates an expected call of CreateSession.
func (mr *MockAPIMockRecorder) CreateSession(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSession", reflect.TypeOf((*MockAPI)(nil).CreateSession), arg0)
}
// CreateTeam mocks base method.
func (m *MockAPI) CreateTeam(arg0 *model.Team) (*model.Team, *model.AppError) {
m.ctrl.T.Helper()
@ -457,6 +472,20 @@ func (mr *MockAPIMockRecorder) ExecuteSlashCommand(arg0 interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteSlashCommand", reflect.TypeOf((*MockAPI)(nil).ExecuteSlashCommand), arg0)
}
// ExtendSessionExpiry mocks base method.
func (m *MockAPI) ExtendSessionExpiry(arg0 string, arg1 int64) *model.AppError {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ExtendSessionExpiry", arg0, arg1)
ret0, _ := ret[0].(*model.AppError)
return ret0
}
// ExtendSessionExpiry indicates an expected call of ExtendSessionExpiry.
func (mr *MockAPIMockRecorder) ExtendSessionExpiry(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtendSessionExpiry", reflect.TypeOf((*MockAPI)(nil).ExtendSessionExpiry), arg0, arg1)
}
// GetBot mocks base method.
func (m *MockAPI) GetBot(arg0 string, arg1 bool) (*model.Bot, *model.AppError) {
m.ctrl.T.Helper()
@ -1573,6 +1602,20 @@ func (mr *MockAPIMockRecorder) InstallPlugin(arg0, arg1 interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallPlugin", reflect.TypeOf((*MockAPI)(nil).InstallPlugin), arg0, arg1)
}
// IsEnterpriseReady mocks base method.
func (m *MockAPI) IsEnterpriseReady() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsEnterpriseReady")
ret0, _ := ret[0].(bool)
return ret0
}
// IsEnterpriseReady indicates an expected call of IsEnterpriseReady.
func (mr *MockAPIMockRecorder) IsEnterpriseReady() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsEnterpriseReady", reflect.TypeOf((*MockAPI)(nil).IsEnterpriseReady))
}
// KVCompareAndDelete mocks base method.
func (m *MockAPI) KVCompareAndDelete(arg0 string, arg1 []byte) (bool, *model.AppError) {
m.ctrl.T.Helper()
@ -2014,6 +2057,20 @@ func (mr *MockAPIMockRecorder) RemoveTeamIcon(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveTeamIcon", reflect.TypeOf((*MockAPI)(nil).RemoveTeamIcon), arg0)
}
// RemoveUserCustomStatus mocks base method.
func (m *MockAPI) RemoveUserCustomStatus(arg0 string) *model.AppError {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RemoveUserCustomStatus", arg0)
ret0, _ := ret[0].(*model.AppError)
return ret0
}
// RemoveUserCustomStatus indicates an expected call of RemoveUserCustomStatus.
func (mr *MockAPIMockRecorder) RemoveUserCustomStatus(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveUserCustomStatus", reflect.TypeOf((*MockAPI)(nil).RemoveUserCustomStatus), arg0)
}
// RequestTrialLicense mocks base method.
func (m *MockAPI) RequestTrialLicense(arg0 string, arg1 int, arg2, arg3 bool) *model.AppError {
m.ctrl.T.Helper()
@ -2028,6 +2085,20 @@ func (mr *MockAPIMockRecorder) RequestTrialLicense(arg0, arg1, arg2, arg3 interf
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestTrialLicense", reflect.TypeOf((*MockAPI)(nil).RequestTrialLicense), arg0, arg1, arg2, arg3)
}
// RevokeSession mocks base method.
func (m *MockAPI) RevokeSession(arg0 string) *model.AppError {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RevokeSession", arg0)
ret0, _ := ret[0].(*model.AppError)
return ret0
}
// RevokeSession indicates an expected call of RevokeSession.
func (mr *MockAPIMockRecorder) RevokeSession(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeSession", reflect.TypeOf((*MockAPI)(nil).RevokeSession), arg0)
}
// RevokeUserAccessToken mocks base method.
func (m *MockAPI) RevokeUserAccessToken(arg0 string) *model.AppError {
m.ctrl.T.Helper()
@ -2042,6 +2113,20 @@ func (mr *MockAPIMockRecorder) RevokeUserAccessToken(arg0 interface{}) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeUserAccessToken", reflect.TypeOf((*MockAPI)(nil).RevokeUserAccessToken), arg0)
}
// RolesGrantPermission mocks base method.
func (m *MockAPI) RolesGrantPermission(arg0 []string, arg1 string) bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RolesGrantPermission", arg0, arg1)
ret0, _ := ret[0].(bool)
return ret0
}
// RolesGrantPermission indicates an expected call of RolesGrantPermission.
func (mr *MockAPIMockRecorder) RolesGrantPermission(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RolesGrantPermission", reflect.TypeOf((*MockAPI)(nil).RolesGrantPermission), arg0, arg1)
}
// SaveConfig mocks base method.
func (m *MockAPI) SaveConfig(arg0 *model.Config) *model.AppError {
m.ctrl.T.Helper()
@ -2437,6 +2522,20 @@ func (mr *MockAPIMockRecorder) UpdateUserActive(arg0, arg1 interface{}) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserActive", reflect.TypeOf((*MockAPI)(nil).UpdateUserActive), arg0, arg1)
}
// UpdateUserCustomStatus mocks base method.
func (m *MockAPI) UpdateUserCustomStatus(arg0 string, arg1 *model.CustomStatus) *model.AppError {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserCustomStatus", arg0, arg1)
ret0, _ := ret[0].(*model.AppError)
return ret0
}
// UpdateUserCustomStatus indicates an expected call of UpdateUserCustomStatus.
func (mr *MockAPIMockRecorder) UpdateUserCustomStatus(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserCustomStatus", reflect.TypeOf((*MockAPI)(nil).UpdateUserCustomStatus), arg0, arg1)
}
// UpdateUserStatus mocks base method.
func (m *MockAPI) UpdateUserStatus(arg0, arg1 string) (*model.Status, *model.AppError) {
m.ctrl.T.Helper()

View File

@ -1,12 +1,12 @@
{
"extends": [
"plugin:mattermost/react",
"plugin:react/recommended",
"plugin:cypress/recommended",
"plugin:jquery/deprecated"
],
"plugins": [
"react",
"babel",
"mattermost",
"import",
"cypress",
"jquery",
@ -25,6 +25,12 @@
}
},
"rules": {
"react/display-name": [
0,
{
"ignoreTranspilerName": false
}
],
"max-lines": "off",
"no-unused-expressions": 0,
"babel/no-unused-expressions": [2, {"allowShortCircuit": true}],
@ -71,6 +77,7 @@
"plugin:@typescript-eslint/recommended"
],
"rules": {
"mattermost/no-dispatch-getstate": 0, // Failing in eslint 8
"import/no-unresolved": 0, // ts handles this better
"camelcase": 0,
"semi": "off",

20343
webapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,29 +23,30 @@
"cypress:open": "cypress open"
},
"dependencies": {
"@draft-js-plugins/editor": "^4.1.0",
"@draft-js-plugins/emoji": "^4.5.5",
"@draft-js-plugins/mention": "^5.0.0",
"@fullcalendar/core": "^5.10.0",
"@fullcalendar/daygrid": "^5.10.0",
"@fullcalendar/interaction": "^5.10.0",
"@fullcalendar/react": "^5.10.0",
"@mattermost/compass-icons": "^0.1.10",
"@reduxjs/toolkit": "^1.6.0",
"@draft-js-plugins/editor": "^4.1.2",
"@draft-js-plugins/emoji": "^4.6.0",
"@draft-js-plugins/mention": "^5.1.2",
"@fullcalendar/core": "^5.10.1",
"@fullcalendar/daygrid": "^5.10.1",
"@fullcalendar/interaction": "^5.10.1",
"@fullcalendar/react": "^5.10.1",
"@mattermost/compass-icons": "^0.1.22",
"@reduxjs/toolkit": "^1.8.0",
"@tippyjs/react": "4.2.6",
"color": "^4.0.0",
"color": "^4.2.1",
"draft-js": "^0.11.7",
"emoji-mart": "^3.0.1",
"fstream": "^1.0.12",
"fullcalendar": "^5.10.0",
"fullcalendar": "^5.10.2",
"imagemin-gifsicle": "^7.0.0",
"imagemin-mozjpeg": "^9.0.0",
"imagemin-mozjpeg": "^10.0.0",
"imagemin-optipng": "^8.0.0",
"imagemin-pngquant": "^9.0.2",
"imagemin-svgo": "^8.0.0",
"imagemin-svgo": "^10.0.1",
"imagemin-webp": "^7.0.0",
"lodash": "^4.17.21",
"marked": "^4.0.12",
"mini-create-react-context": "^0.4.1",
"moment": "^2.29.1",
"nanoevents": "^5.1.13",
"react": "^17.0.2",
@ -55,11 +56,11 @@
"react-dnd-scrolling": "^1.2.1",
"react-dnd-touch-backend": "^14.0.0",
"react-dom": "^17.0.2",
"react-hot-keys": "^2.6.2",
"react-hotkeys-hook": "^3.3.0",
"react-intl": "^5.13.5",
"react-redux": "^7.2.4",
"react-router-dom": "^5.2.0",
"react-hot-keys": "^2.7.1",
"react-hotkeys-hook": "^3.4.4",
"react-intl": "^5.24.7",
"react-redux": "^7.2.6",
"react-router-dom": "^5.2.1",
"react-select": "^4.3.0",
"trim-newlines": "^4.0.2"
},
@ -74,8 +75,13 @@
}
},
"transform": {
"^.+\\.tsx?$": "ts-jest"
"^.+\\.tsx?$": "@swc/jest"
},
"transformIgnorePatterns": [
"/nanoevents/"
],
"maxWorkers": "50%",
"testEnvironment": "jsdom",
"collectCoverage": true,
"collectCoverageFrom": [
"src/**/*.{ts,tsx,js,jsx}",
@ -83,67 +89,69 @@
]
},
"devDependencies": {
"@formatjs/cli": "^3.2.0",
"@formatjs/ts-transformer": "^3.2.1",
"@formatjs/cli": "^4.8.2",
"@formatjs/ts-transformer": "^3.9.2",
"@swc/jest": "^0.2.20",
"@testing-library/cypress": "^8.0.2",
"@testing-library/dom": "^7.31.2",
"@testing-library/jest-dom": "^5.11.10",
"@testing-library/dom": "^8.11.4",
"@testing-library/jest-dom": "^5.16.3",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.9",
"@types/color": "^3.0.2",
"@types/draft-js": "^0.11.6",
"@types/emoji-mart": "^3.0.4",
"@types/jest": "^26.0.21",
"@types/marked": "^4.0.1",
"@testing-library/user-event": "^13.5.0",
"@types/color": "^3.0.3",
"@types/draft-js": "^0.11.9",
"@types/emoji-mart": "^3.0.9",
"@types/jest": "^27.4.1",
"@types/marked": "^4.0.3",
"@types/nanoevents": "^1.0.0",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.3",
"@types/react": "^17.0.43",
"@types/react-dom": "^17.0.14",
"@types/react-intl": "^3.0.0",
"@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7",
"@types/react-redux": "^7.1.23",
"@types/react-router-dom": "^5.3.3",
"@types/react-select": "^4.0.13",
"@types/redux-mock-store": "^1.0.3",
"@typescript-eslint/eslint-plugin": "^4.19.0",
"@typescript-eslint/parser": "^4.19.0",
"copy-webpack-plugin": "^8.1.0",
"@typescript-eslint/eslint-plugin": "^5.16.0",
"@typescript-eslint/parser": "^5.16.0",
"copy-webpack-plugin": "^10.2.4",
"cross-env": "^7.0.3",
"css-loader": "^5.2.0",
"cypress": "^9.5.0",
"cypress-failed-log": "^2.9.2",
"cypress-real-events": "^1.6.0",
"eslint": "^7.22.0",
"eslint-import-resolver-webpack": "0.13.0",
"css-loader": "^6.7.1",
"cypress": "^9.5.2",
"cypress-failed-log": "^2.9.5",
"cypress-real-events": "^1.7.0",
"eslint": "^8.11.0",
"eslint-import-resolver-webpack": "0.13.2",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-cypress": "2.11.2",
"eslint-plugin-cypress": "2.12.1",
"eslint-plugin-header": "3.1.1",
"eslint-plugin-import": "2.22.1",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-jquery": "1.5.1",
"eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#070ce792d105482ffb2b27cfc0b7e78b3d20acee",
"eslint-plugin-no-only-tests": "2.4.0",
"eslint-plugin-react": "7.23.1",
"eslint-plugin-no-only-tests": "2.6.0",
"eslint-plugin-react": "7.29.4",
"fetch-mock-jest": "^1.5.1",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.3.1",
"html-webpack-plugin": "^5.5.0",
"image-webpack-loader": "^8.1.0",
"isomorphic-fetch": "^3.0.0",
"jest": "^26.6.3",
"prettier": "^2.2.1",
"jest": "^27.5.1",
"jest-mock": "^27.5.1",
"prettier": "^2.6.1",
"redux-mock-store": "^1.5.4",
"sass": "^1.32.8",
"sass-loader": "^11.0.1",
"start-server-and-test": "^1.12.1",
"style-loader": "^2.0.0",
"stylelint": "^13.13.1",
"stylelint-config-sass-guidelines": "^8.0.0",
"terser-webpack-plugin": "^5.1.1",
"ts-jest": "^26.5.4",
"ts-loader": "^8.0.18",
"typescript": "^4.2.3",
"webpack": "^5.28.0",
"webpack-cli": "^4.5.0",
"webpack-merge": "^5.7.3"
"sass": "^1.49.9",
"sass-loader": "^12.6.0",
"start-server-and-test": "^1.14.0",
"style-loader": "^3.3.1",
"stylelint": "^14.6.1",
"stylelint-config-sass-guidelines": "^9.0.1",
"terser-webpack-plugin": "^5.3.1",
"ts-jest": "^27.1.4",
"ts-loader": "^9.2.8",
"typescript": "^4.6.3",
"webpack": "^5.70.0",
"webpack-cli": "^4.9.2",
"webpack-merge": "^5.8.0"
},
"optionalDependencies": {
"cypress": "^6.8.0"
"cypress": "^9.5.2"
}
}

View File

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {createFilterClause} from './blocks/filterClause'

View File

@ -2403,15 +2403,15 @@ exports[`src/components/workspace show add new view tooltip 1`] = `
data-escaped=""
data-placement="bottom-start"
data-reference-hidden=""
data-state="hidden"
data-state="visible"
role="tooltip"
style="max-width: 320px; transition-duration: 0ms;"
style="max-width: 320px; transition-duration: 250ms;"
tabindex="-1"
>
<div
class="tippy-content"
data-state="hidden"
style="transition-duration: 0ms;"
data-state="visible"
style="transition-duration: 250ms;"
>
<div>
<div>

View File

@ -6,7 +6,7 @@ import {render, screen, waitFor} from '@testing-library/react'
import '@testing-library/jest-dom'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import userEvent from '@testing-library/user-event'

View File

@ -2,13 +2,13 @@
// See LICENSE.txt for license information.
import React from 'react'
import {fireEvent, render, screen} from '@testing-library/react'
import {fireEvent, render, screen, act} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import mutator from '../mutator'
@ -82,19 +82,24 @@ describe('components/blockIconSelector', () => {
expect(mockedMutator.changeBlockIcon).toBeCalledTimes(1)
})
test('return a new icon after click on EmojiPicker', async () => {
const {container} = render(wrapIntl(
test('return a new icon after click on EmojiPicker', () => {
const {container, getByRole, getAllByRole} = render(wrapIntl(
<BlockIconSelector
block={card}
size='l'
/>,
))
userEvent.click(screen.getByRole('button', {name: 'menuwrapper'}))
act(() => {
userEvent.click(getByRole('button', {name: 'menuwrapper'}))
})
const menuPicker = container.querySelector('div#pick')
expect(menuPicker).not.toBeNull()
fireEvent.mouseEnter(menuPicker!)
const allButtonThumbUp = await screen.findAllByRole('button', {name: /thumbsup/i})
act(() => {
fireEvent.mouseEnter(menuPicker!)
})
const allButtonThumbUp = getAllByRole('button', {name: /thumbsup/i})
userEvent.click(allButtonThumbUp[0])
expect(mockedMutator.changeBlockIcon).toBeCalledTimes(1)
expect(mockedMutator.changeBlockIcon).toBeCalledWith(card.boardId, card.id, card.fields.icon, '👍')

View File

@ -6,7 +6,7 @@ import React from 'react'
import {MockStoreEnhanced} from 'redux-mock-store'
import {createMemoryHistory} from 'history'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {Provider as ReduxProvider} from 'react-redux'

View File

@ -10,7 +10,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {FetchMock} from '../../test/fetchMock'
import {TestBlockFactory} from '../../test/testBlockFactory'
@ -90,6 +90,11 @@ describe('components/cardDetail/CardDetail', () => {
},
current: card.id,
},
clientConfig: {
value: {
featureFlags: {},
},
},
})
const component = (
@ -226,6 +231,11 @@ describe('components/cardDetail/CardDetail', () => {
},
current: welcomeCard.id,
},
clientConfig: {
value: {
featureFlags: {},
},
},
})
const onboardingBoard = TestBlockFactory.createBoard()
@ -326,6 +336,11 @@ describe('components/cardDetail/CardDetail', () => {
},
current: welcomeCard.id,
},
clientConfig: {
value: {
featureFlags: {},
},
},
})
const onboardingBoard = TestBlockFactory.createBoard()
@ -424,6 +439,11 @@ describe('components/cardDetail/CardDetail', () => {
},
current: welcomeCard.id,
},
clientConfig: {
value: {
featureFlags: {},
},
},
}
const store = mockStore(state)

View File

@ -72,6 +72,11 @@ describe('components/cardDetail/cardDetailContents', () => {
},
current: card.id,
},
clientConfig: {
value: {
featureFlags: {},
},
},
}
const store = mockStateStore([], state)
const wrap = (child: ReactNode): ReactElement => (

View File

@ -4,7 +4,7 @@
import React from 'react'
import {render, screen, act} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import '@testing-library/jest-dom'
import {createIntl} from 'react-intl'
@ -99,6 +99,11 @@ describe('components/cardDetail/CardDetailProperties', () => {
},
current: card.id,
},
clientConfig: {
value: {
featureFlags: {},
},
},
}
const mockStore = configureStore([])

View File

@ -6,7 +6,7 @@ import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import moment from 'moment'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {wrapIntl, mockStateStore} from '../../testUtils'

View File

@ -64,6 +64,11 @@ describe('components/cardDetail/CommentsList', () => {
},
current: 'card_id_1',
},
clientConfig: {
value: {
featureFlags: {},
},
},
})
const component = (

View File

@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event'
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import mutator from '../mutator'
import {Utils} from '../utils'

View File

@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, render, screen, within} from '@testing-library/react'
import {fireEvent, render, screen, within, act} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {Provider as ReduxProvider} from 'react-redux'
import {mockDOM, mockStateStore, wrapDNDIntl} from '../testUtils'
@ -277,9 +277,11 @@ describe('components/centerPanel', () => {
</ReduxProvider>,
))
const cardElement = screen.getByRole('textbox', {name: 'card1'})
expect(cardElement).not.toBeNull()
userEvent.click(cardElement, {shiftKey: true})
act(() => {
const cardElement = screen.getByRole('textbox', {name: 'card1'})
expect(cardElement.parentNode).not.toBeNull()
userEvent.click(cardElement as HTMLElement, {shiftKey: true})
})
expect(container).toMatchSnapshot()
//escape
@ -303,16 +305,20 @@ describe('components/centerPanel', () => {
</ReduxProvider>,
))
//select card1
const card1Element = screen.getByRole('textbox', {name: 'card1'})
expect(card1Element).not.toBeNull()
userEvent.click(card1Element, {shiftKey: true})
act(() => {
//select card1
const card1Element = screen.getByRole('textbox', {name: 'card1'})
expect(card1Element).not.toBeNull()
userEvent.click(card1Element, {shiftKey: true})
})
expect(container).toMatchSnapshot()
//select card2
const card2Element = screen.getByRole('textbox', {name: 'card2'})
expect(card2Element).not.toBeNull()
userEvent.click(card2Element, {shiftKey: true, ctrlKey: true})
act(() => {
//select card2
const card2Element = screen.getByRole('textbox', {name: 'card2'})
expect(card2Element).not.toBeNull()
userEvent.click(card2Element, {shiftKey: true, ctrlKey: true})
})
expect(container).toMatchSnapshot()
//escape
@ -335,9 +341,11 @@ describe('components/centerPanel', () => {
/>
</ReduxProvider>,
))
const cardElement = screen.getByRole('textbox', {name: 'card1'})
expect(cardElement).not.toBeNull()
userEvent.click(cardElement, {shiftKey: true})
act(() => {
const cardElement = screen.getByRole('textbox', {name: 'card1'})
expect(cardElement).not.toBeNull()
userEvent.click(cardElement, {shiftKey: true})
})
expect(container).toMatchSnapshot()
//delete
@ -360,9 +368,11 @@ describe('components/centerPanel', () => {
/>
</ReduxProvider>,
))
const cardElement = screen.getByRole('textbox', {name: 'card1'})
expect(cardElement).not.toBeNull()
userEvent.click(cardElement, {shiftKey: true})
act(() => {
const cardElement = screen.getByRole('textbox', {name: 'card1'})
expect(cardElement).not.toBeNull()
userEvent.click(cardElement, {shiftKey: true})
})
expect(container).toMatchSnapshot()
//ctrl+d

View File

@ -4,7 +4,7 @@
import React, {ReactElement, ReactNode} from 'react'
import {fireEvent, render, screen, waitFor} from '@testing-library/react'
import '@testing-library/jest-dom'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import userEvent from '@testing-library/user-event'
import {wrapIntl} from '../../testUtils'

View File

@ -6,7 +6,7 @@ import {render} from '@testing-library/react'
import {act} from 'react-dom/test-utils'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {ImageBlock} from '../../blocks/imageBlock'

View File

@ -7,7 +7,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import '@testing-library/jest-dom'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {TextBlock} from '../../blocks/textBlock'

View File

@ -5,7 +5,7 @@ import '@testing-library/jest-dom'
import {act, render, screen} from '@testing-library/react'
import React, {ReactNode, ReactElement} from 'react'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {Provider as ReduxProvider} from 'react-redux'
import userEvent from '@testing-library/user-event'

View File

@ -8,7 +8,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {wrapDNDIntl, mockStateStore, blocksById} from '../../testUtils'

View File

@ -8,7 +8,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {MockStoreEnhanced} from 'redux-mock-store'

View File

@ -5,12 +5,12 @@ import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {createMemoryHistory} from 'history'
import {render} from '@testing-library/react'
import {render, act} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import configureStore from 'redux-mock-store'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {wrapIntl} from '../../testUtils'
@ -77,8 +77,12 @@ describe('components/sidebar/GlobalHeaderSettingsMenu', () => {
)
const {container} = render(component)
userEvent.click(container.querySelector('.menu-entry') as Element)
userEvent.hover(container.querySelector('#lang') as Element)
act(() => {
userEvent.click(container.querySelector('.menu-entry') as Element)
})
act(() => {
userEvent.hover(container.querySelector('#lang') as Element)
})
expect(container).toMatchSnapshot()
})
@ -91,8 +95,12 @@ describe('components/sidebar/GlobalHeaderSettingsMenu', () => {
)
const {container} = render(component)
userEvent.click(container.querySelector('.menu-entry') as Element)
userEvent.hover(container.querySelector('#import') as Element)
act(() => {
userEvent.click(container.querySelector('.menu-entry') as Element)
})
act(() => {
userEvent.hover(container.querySelector('#import') as Element)
})
expect(container).toMatchSnapshot()
userEvent.click(container.querySelector('[aria-label="Asana"]') as Element)

View File

@ -373,6 +373,7 @@ exports[`components/kanban/calculations/KanbanCalculationOptions with submenu op
</span>
<div
class="dropdown-submenu"
style="top: -10px; left: 0px;"
>
<div
class="drops "

View File

@ -6,7 +6,7 @@ import '@testing-library/jest-dom'
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {MemoryRouter} from 'react-router-dom'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import userEvent from '@testing-library/user-event'
import {IPropertyOption, IPropertyTemplate} from '../../blocks/board'

View File

@ -9,7 +9,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import Mutator from '../../mutator'
import {Utils} from '../../utils'

View File

@ -4,7 +4,7 @@ import React from 'react'
import {fireEvent, render, screen, within} from '@testing-library/react'
import {createIntl} from 'react-intl'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {Provider as ReduxProvider} from 'react-redux'
import Mutator from '../../mutator'

View File

@ -5,7 +5,7 @@ import {render, screen, within} from '@testing-library/react'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'
import {createIntl} from 'react-intl'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {wrapDNDIntl} from '../../testUtils'
import Mutator from '../../mutator'

View File

@ -38,6 +38,11 @@ describe('components/onboardingTour/addComments/AddCommentTourStep', () => {
},
current: 'card_id_1',
},
clientConfig: {
value: {
featureFlags: {},
},
},
}
let store = mockStore(state)

View File

@ -38,6 +38,11 @@ describe('components/onboardingTour/addComments/AddDescriptionTourStep', () => {
},
current: 'card_id_1',
},
clientConfig: {
value: {
featureFlags: {},
},
},
}
let store = mockStore(state)

View File

@ -38,6 +38,11 @@ describe('components/onboardingTour/addComments/AddPropertiesTourStep', () => {
},
current: 'card_id_1',
},
clientConfig: {
value: {
featureFlags: {},
},
},
}
let store = mockStore(state)

View File

@ -32,6 +32,11 @@ describe('components/onboardingTour/addComments/AddViewTourStep', () => {
},
current: 'board_id_1',
},
clientConfig: {
value: {
featureFlags: {},
},
},
}
let store = mockStore(state)

View File

@ -32,6 +32,11 @@ describe('components/onboardingTour/addComments/CopyLinkTourStep', () => {
},
current: 'board_id_1',
},
clientConfig: {
value: {
featureFlags: {},
},
},
}
let store = mockStore(state)

View File

@ -38,6 +38,11 @@ describe('components/onboardingTour/addComments/OpenCardTourStep', () => {
},
current: 'card_id_1',
},
clientConfig: {
value: {
featureFlags: {},
},
},
}
let store = mockStore(state)

View File

@ -38,6 +38,11 @@ describe('components/onboardingTour/addComments/ShareBoardTourStep', () => {
},
current: 'card_id_1',
},
clientConfig: {
value: {
featureFlags: {},
},
},
}
let store = mockStore(state)

View File

@ -11,6 +11,8 @@ import {OnboardingBoardTitle, OnboardingCardTitle} from '../../cardDetail/cardDe
import {getOnboardingTourCategory, getOnboardingTourStarted, getOnboardingTourStep} from '../../../store/users'
import TourTip from '../../tutorial_tour_tip/tutorial_tour_tip'
import {TutorialTourTipPunchout} from '../../tutorial_tour_tip/tutorial_tour_tip_backdrop'
import {ClientConfig} from '../../../config/clientConfig'
import {getClientConfig} from '../../../store/clientConfig'
type Props = {
requireCard: boolean
@ -30,12 +32,13 @@ type Props = {
const TourTipRenderer = (props: Props): JSX.Element | null => {
const board = useAppSelector(getCurrentBoard)
const clientConfig = useAppSelector<ClientConfig>(getClientConfig)
const isOnboardingBoard = board ? board.title === OnboardingBoardTitle : false
const onboardingTourStarted = useAppSelector(getOnboardingTourStarted)
const onboardingTourCategory = useAppSelector(getOnboardingTourCategory)
const onboardingTourStep = useAppSelector(getOnboardingTourStep)
const showTour = isOnboardingBoard && onboardingTourStarted && onboardingTourCategory === props.category
const showTour = !clientConfig.featureFlags.disableTour && isOnboardingBoard && onboardingTourStarted && onboardingTourCategory === props.category
let showTourTip = showTour && onboardingTourStep === props.step.toString()
if (props.requireCard) {

View File

@ -4,7 +4,7 @@
import React, {useState} from 'react'
import {render, screen} from '@testing-library/react'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'

View File

@ -7,7 +7,7 @@ import thunk from 'redux-thunk'
import React from 'react'
import {MemoryRouter} from 'react-router'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {IUser} from '../../user'
import {ISharing} from '../../blocks/sharing'

View File

@ -4,12 +4,12 @@
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {render} from '@testing-library/react'
import {render, act} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import configureStore from 'redux-mock-store'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {wrapIntl} from '../../testUtils'
@ -73,8 +73,12 @@ describe('components/sidebar/SidebarSettingsMenu', () => {
)
const {container} = render(component)
userEvent.click(container.querySelector('.menu-entry') as Element)
userEvent.hover(container.querySelector('#theme') as Element)
act(() => {
userEvent.click(container.querySelector('.menu-entry') as Element)
})
act(() => {
userEvent.hover(container.querySelector('#theme') as Element)
})
expect(container).toMatchSnapshot()
})
@ -86,8 +90,12 @@ describe('components/sidebar/SidebarSettingsMenu', () => {
)
const {container} = render(component)
userEvent.click(container.querySelector('.menu-entry') as Element)
userEvent.hover(container.querySelector('#lang') as Element)
act(() => {
userEvent.click(container.querySelector('.menu-entry') as Element)
})
act(() => {
userEvent.hover(container.querySelector('#lang') as Element)
})
expect(container).toMatchSnapshot()
})
@ -100,8 +108,12 @@ describe('components/sidebar/SidebarSettingsMenu', () => {
)
const {container} = render(component)
userEvent.click(container.querySelector('.menu-entry') as Element)
userEvent.hover(container.querySelector('#import') as Element)
act(() => {
userEvent.click(container.querySelector('.menu-entry') as Element)
})
act(() => {
userEvent.hover(container.querySelector('#import') as Element)
})
expect(container).toMatchSnapshot()
userEvent.click(container.querySelector('[aria-label="Asana"]') as Element)

View File

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {render} from '@testing-library/react'
import React from 'react'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {wrapDNDIntl} from '../testUtils'
import {Constants} from '../constants'

View File

@ -7,7 +7,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {wrapIntl, mockStateStore} from '../../testUtils'

View File

@ -5,7 +5,7 @@ import React from 'react'
import {render, screen} from '@testing-library/react'
import {Provider as ReduxProvider} from 'react-redux'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'

View File

@ -8,7 +8,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {FilterClause} from '../../blocks/filterClause'

View File

@ -8,7 +8,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {FilterClause} from '../../blocks/filterClause'

View File

@ -8,7 +8,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {wrapIntl, mockStateStore} from '../../testUtils'

View File

@ -8,7 +8,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {TestBlockFactory} from '../../test/testBlockFactory'

View File

@ -6,7 +6,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {MockStoreEnhanced} from 'redux-mock-store'

View File

@ -7,7 +7,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {BoardView} from '../../blocks/boardView'

View File

@ -7,7 +7,7 @@ import {Provider as ReduxProvider} from 'react-redux'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import {wrapIntl, mockStateStore} from '../../testUtils'

View File

@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event'
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import mutator from '../mutator'
import {Utils} from '../utils'

View File

@ -4,7 +4,7 @@ import {act, render, waitFor} from '@testing-library/react'
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {MemoryRouter} from 'react-router-dom'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import userEvent from '@testing-library/user-event'
@ -449,15 +449,15 @@ describe('src/components/workspace', () => {
<Workspace readonly={false}/>
</ReduxProvider>,
), {wrapper: MemoryRouter})
jest.runOnlyPendingTimers()
})
await waitFor(() => {
const elements = document.querySelectorAll('.AddViewTourStep')
expect(elements).toBeDefined()
expect(elements.length).toBe(2)
expect(elements[1]).toMatchSnapshot()
})
jest.runOnlyPendingTimers()
await waitFor(() => expect(document.querySelectorAll('.AddViewTourStep')).toBeDefined(), {timeout: 5000})
const elements = document.querySelectorAll('.AddViewTourStep')
expect(elements.length).toBe(2)
expect(elements[1]).toMatchSnapshot()
})
test('show copy link tooltip', async () => {

View File

@ -261,7 +261,11 @@ class OctoClient {
}
async getAllBlocks(boardID: string): Promise<Block[]> {
const path = `/api/v1/boards/${boardID}/blocks?all=true`
let path = `/api/v1/boards/${boardID}/blocks?all=true`
const readToken = Utils.getReadToken()
if (readToken) {
path += `&read_token=${readToken}`
}
return this.getBlocksWithPath(path)
}
@ -619,7 +623,11 @@ class OctoClient {
}
async getBoard(boardID: string): Promise<Board | undefined> {
const path = `/api/v1/boards/${boardID}`
let path = `/api/v1/boards/${boardID}`
const readToken = Utils.getReadToken()
if (readToken) {
path += `?read_token=${readToken}`
}
const response = await fetch(this.getBaseURL() + path, {
method: 'GET',
headers: this.headers(),

View File

@ -80,13 +80,10 @@ const BoardPage = (props: Props): JSX.Element => {
return initialLoad
}, [props.readonly])
const loadOrJoinBoard = useCallback(async (userId: string, boardTeamId: string, boardId: string, viewId: string) => {
// set the active board if we're able to pick one
dispatch(setCurrentBoard(boardId))
const loadOrJoinBoard = useCallback(async (userId: string, boardTeamId: string, boardId: string) => {
// and fetch its data
const result: any = await dispatch(loadBoardData(boardId))
if (result.payload.blocks.length === 0) {
if (result.payload.blocks.length === 0 && userId) {
const member = await octoClient.createBoardMember({userId, boardId})
if (!member) {
UserSettings.setLastBoardID(boardTeamId, null)
@ -101,23 +98,28 @@ const BoardPage = (props: Props): JSX.Element => {
teamId: boardTeamId,
boardId,
}))
// and set it as most recently viewed board
UserSettings.setLastBoardID(boardTeamId, boardId)
if (viewId && viewId !== '0') {
dispatch(setCurrentView(viewId))
UserSettings.setLastViewId(boardId, viewId)
}
}, [])
useEffect(() => {
dispatch(loadAction(match.params.boardId))
if (match.params.boardId && me) {
loadOrJoinBoard(me.id, teamId, match.params.boardId, match.params.viewId)
if (match.params.boardId) {
// set the active board
dispatch(setCurrentBoard(match.params.boardId))
// and set it as most recently viewed board
UserSettings.setLastBoardID(teamId, match.params.boardId)
if (match.params.viewId && match.params.viewId !== '0') {
dispatch(setCurrentView(match.params.viewId))
UserSettings.setLastViewId(match.params.boardId, match.params.viewId)
}
if (!props.readonly && me) {
loadOrJoinBoard(me.id, teamId, match.params.boardId)
}
}
}, [teamId, match.params.boardId, match.params.viewId])
}, [teamId, match.params.boardId, match.params.viewId, me?.id])
if (props.readonly) {
useEffect(() => {

View File

@ -14,7 +14,7 @@ import userEvent from '@testing-library/user-event'
import configureStore from 'redux-mock-store'
import {mocked} from 'ts-jest/utils'
import {mocked} from 'jest-mock'
import thunk from 'redux-thunk'

View File

@ -4,7 +4,6 @@ import React from 'react'
import {
Redirect,
Route,
useRouteMatch,
} from 'react-router-dom'
import {Utils} from './utils'
@ -12,6 +11,8 @@ import {getLoggedIn, getMe} from './store/users'
import {useAppSelector} from './store/hooks'
import {UserSettingKey} from './userSettings'
import {IUser, UserPropPrefix} from './user'
import {getClientConfig} from './store/clientConfig'
import {ClientConfig} from './config/clientConfig'
type RouteProps = {
path: string|string[]
@ -25,46 +26,51 @@ type RouteProps = {
function FBRoute(props: RouteProps) {
const loggedIn = useAppSelector<boolean|null>(getLoggedIn)
const match = useRouteMatch<any>()
const me = useAppSelector<IUser|null>(getMe)
const clientConfig = useAppSelector<ClientConfig>(getClientConfig)
let originalPath
if (props.getOriginalPath) {
originalPath = props.getOriginalPath(match)
}
let redirect: React.ReactNode = null
if (Utils.isFocalboardPlugin() && (me?.id !== 'single-user') && props.path !== '/welcome' && loggedIn === true && !me?.props[UserPropPrefix + UserSettingKey.WelcomePageViewed]) {
if (originalPath) {
return <Redirect to={`/welcome?r=${originalPath}`}/>
}
return <Redirect to='/welcome'/>
}
const showWelcomePage = !clientConfig.featureFlags.disableTour &&
Utils.isFocalboardPlugin() &&
(me?.id !== 'single-user') &&
props.path !== '/welcome' &&
loggedIn === true &&
!me?.props[UserPropPrefix + UserSettingKey.WelcomePageViewed]
if (loggedIn === false && props.loginRequired) {
if (originalPath) {
let redirectUrl = '/' + Utils.buildURL(originalPath)
if (redirectUrl.indexOf('//') === 0) {
redirectUrl = redirectUrl.slice(1)
if (showWelcomePage) {
redirect = ({match}: any) => {
if (props.getOriginalPath) {
return <Redirect to={`/welcome?r=${props.getOriginalPath!(match)}`}/>
}
const loginUrl = `/error?id=not-logged-in&r=${encodeURIComponent(redirectUrl)}`
return <Redirect to={loginUrl}/>
return <Redirect to='/welcome'/>
}
return <Redirect to='/error?id=not-logged-in'/>
}
if (loggedIn === true || !props.loginRequired) {
return (
<Route
path={props.path}
render={props.render}
component={props.component}
exact={props.exact}
>
{props.children}
</Route>
)
if (redirect === null && loggedIn === false && props.loginRequired) {
redirect = ({match}: any) => {
if (props.getOriginalPath) {
let redirectUrl = '/' + Utils.buildURL(props.getOriginalPath!(match))
if (redirectUrl.indexOf('//') === 0) {
redirectUrl = redirectUrl.slice(1)
}
const loginUrl = `/error?id=not-logged-in&r=${encodeURIComponent(redirectUrl)}`
return <Redirect to={loginUrl}/>
}
return <Redirect to='/error?id=not-logged-in'/>
}
}
return null
return (
<Route
path={props.path}
render={props.render}
component={props.component}
exact={props.exact}
>
{redirect || props.children}
</Route>
)
}
export default React.memo(FBRoute)

View File

@ -8,6 +8,7 @@ import {
useRouteMatch,
useHistory,
generatePath,
useLocation,
} from 'react-router-dom'
import {createBrowserHistory, History} from 'history'
@ -70,16 +71,21 @@ function HomeToCurrentTeam(props: {path: string, exact: boolean}) {
function WorkspaceToTeamRedirect() {
const match = useRouteMatch<{boardId: string, viewId: string, cardId?: string, workspaceId?: string}>()
const queryParams = new URLSearchParams(useLocation().search)
const history = useHistory()
useEffect(() => {
octoClient.getBoard(match.params.boardId).then((board) => {
if (board) {
history.replace(generatePath('/team/:teamId/:boardId?/:viewId?/:cardId?', {
let newPath = generatePath(match.path.replace('/workspace/:workspaceId', '/team/:teamId'), {
teamId: board?.teamId,
boardId: board?.id,
viewId: match.params.viewId,
cardId: match.params.cardId,
}))
})
if (queryParams) {
newPath += '?' + queryParams
}
history.replace(newPath)
}
})
}, [])
@ -159,9 +165,10 @@ const FocalboardRouter = (props: Props): JSX.Element => {
<ChangePasswordPage/>
</FBRoute>}
<FBRoute path='/shared/:boardId?/:viewId?/:cardId?'>
<FBRoute path={['/team/:teamId/shared/:boardId?/:viewId?/:cardId?', '/shared/:boardId?/:viewId?/:cardId?']}>
<BoardPage readonly={true}/>
</FBRoute>
<FBRoute
loginRequired={true}
path='/board/:boardId?/:viewId?/:cardId?'
@ -171,7 +178,7 @@ const FocalboardRouter = (props: Props): JSX.Element => {
>
<BoardPage/>
</FBRoute>
<FBRoute path={['/workspace/:workspaceId/:boardId?/:viewId?/:cardId?', '/workspace/:workspaceId/shared/:boardId?/:viewId?/:cardId?']}>
<FBRoute path={['/workspace/:workspaceId/shared/:boardId?/:viewId?/:cardId?', '/workspace/:workspaceId/:boardId?/:viewId?/:cardId?']}>
<WorkspaceToTeamRedirect/>
</FBRoute>
<FBRoute

View File

@ -7,7 +7,7 @@ import {default as client} from '../octoClient'
import {Board, BoardMember} from '../blocks/board'
import {IUser} from '../user'
import {initialLoad, loadBoardData} from './initialLoad'
import {initialLoad, initialReadOnlyLoad, loadBoardData} from './initialLoad'
import {addBoardUsers} from './users'
@ -147,6 +147,17 @@ const boardsSlice = createSlice({
builder.addCase(loadBoardData.rejected, (state) => {
state.loadingBoard = false
})
builder.addCase(initialReadOnlyLoad.fulfilled, (state, action) => {
state.boards = {}
state.templates = {}
if (action.payload.board) {
if (action.payload.board.isTemplate) {
state.templates[action.payload.board.id] = action.payload.board
} else {
state.boards[action.payload.board.id] = action.payload.board
}
}
})
builder.addCase(initialLoad.fulfilled, (state, action) => {
state.boards = {}
action.payload.boards.forEach((board) => {

View File

@ -59,7 +59,7 @@ const cardsSlice = createSlice({
builder.addCase(initialReadOnlyLoad.fulfilled, (state, action) => {
state.cards = {}
state.templates = {}
for (const block of action.payload) {
for (const block of action.payload.blocks) {
if (block.type === 'card' && block.fields.isTemplate) {
state.templates[block.id] = block as Card
} else if (block.type === 'card' && !block.fields.isTemplate) {
@ -109,7 +109,7 @@ export function getCard(cardId: string): (state: RootState) => Card|undefined {
}
export const getCurrentBoardCards = createSelector(
(state) => state.boards.current,
(state: RootState) => state.boards.current,
getCards,
(boardId, cards) => {
return Object.values(cards).filter((c) => c.boardId === boardId) as Card[]
@ -117,7 +117,7 @@ export const getCurrentBoardCards = createSelector(
)
export const getCurrentBoardTemplates = createSelector(
(state) => state.boards.current,
(state: RootState) => state.boards.current,
getTemplates,
(boardId, templates) => {
return Object.values(templates).filter((c) => c.boardId === boardId) as Card[]

View File

@ -53,7 +53,7 @@ const commentsSlice = createSlice({
builder.addCase(initialReadOnlyLoad.fulfilled, (state, action) => {
state.comments = {}
state.commentsByCard = {}
for (const block of action.payload) {
for (const block of action.payload.blocks) {
if (block.type === 'comment') {
state.comments[block.id] = block as CommentBlock
state.commentsByCard[block.parentId] = state.commentsByCard[block.parentId] || []

View File

@ -54,7 +54,7 @@ const contentsSlice = createSlice({
builder.addCase(initialReadOnlyLoad.fulfilled, (state, action) => {
state.contents = {}
state.contentsByCard = {}
for (const block of action.payload) {
for (const block of action.payload.blocks) {
if (block.type !== 'board' && block.type !== 'view' && block.type !== 'comment') {
state.contents[block.id] = block as ContentBlock
state.contentsByCard[block.parentId] = state.contentsByCard[block.parentId] || []
@ -90,8 +90,8 @@ export const getContents = createSelector(
export function getCardContents(cardId: string): (state: RootState) => Array<ContentBlock|ContentBlock[]> {
return createSelector(
(state) => (state.contents?.contentsByCard && state.contents.contentsByCard[cardId]) || [],
(state) => getCards(state)[cardId]?.fields?.contentOrder || getTemplates(state)[cardId]?.fields?.contentOrder,
(state: RootState) => (state.contents?.contentsByCard && state.contents.contentsByCard[cardId]) || [],
(state: RootState) => getCards(state)[cardId]?.fields?.contentOrder || getTemplates(state)[cardId]?.fields?.contentOrder,
(contents, contentOrder): Array<ContentBlock|ContentBlock[]> => {
const result: Array<ContentBlock|ContentBlock[]> = []
if (!contents) {

View File

@ -37,8 +37,12 @@ export const initialLoad = createAsyncThunk(
export const initialReadOnlyLoad = createAsyncThunk(
'initialReadOnlyLoad',
async (boardId: string) => {
const blocks = client.getSubtree(boardId, 3)
return blocks
const [board, blocks] = await Promise.all([
client.getBoard(boardId),
client.getAllBlocks(boardId),
])
return {board, blocks}
},
)

View File

@ -79,7 +79,7 @@ const viewsSlice = createSlice({
extraReducers: (builder) => {
builder.addCase(initialReadOnlyLoad.fulfilled, (state, action) => {
state.views = {}
for (const block of action.payload) {
for (const block of action.payload.blocks) {
if (block.type === 'view') {
state.views[block.id] = block as BoardView
}
@ -129,7 +129,7 @@ export function getView(viewId: string): (state: RootState) => BoardView|null {
}
export const getCurrentBoardViews = createSelector(
(state) => state.boards.current,
(state: RootState) => state.boards.current,
getViews,
(boardId, views) => {
Utils.log(`getCurrentBoardViews boardId: ${boardId} views: ${views.length}`)

View File

@ -68,19 +68,10 @@ function makeCommonConfig() {
},
{
test: /\.(eot|tiff|svg|woff2|woff|ttf|png|jpg|jpeg|gif)$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'static',
},
},
{
loader: 'image-webpack-loader',
options: {},
},
],
type: 'asset/resource',
generator: {
filename: 'static/[name].[ext]',
}
},
],
},