1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-01-23 18:34:02 +02:00

Merge branch 'main' into gh-1059-fix-empty-placeholder-in-card-dialog

This commit is contained in:
kamre 2021-09-01 13:13:31 +07:00 committed by GitHub
commit 58f9c7e42f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1745 additions and 321 deletions

View File

@ -1,6 +1,6 @@
module github.com/mattermost/focalboard/linux
go 1.15
go 1.16
replace github.com/mattermost/focalboard/server => ../server

View File

@ -230,6 +230,7 @@ github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqL
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.12.0 h1:mRhaKNwANqRgUBGKmnI5ZxEk7QXmjQeCcuYFMX2bfcc=
github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
@ -404,6 +405,7 @@ github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-hclog v0.16.1 h1:IVQwpTGNRRIHafnTs2dQLIk4ENtneRIEEJWOVDqz99o=
github.com/hashicorp/go-hclog v0.16.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
@ -413,6 +415,7 @@ github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHh
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.4.2 h1:yFvG3ufXXpqiMiZx9HLcaK3XbIqQ1WJFR/F1a2CuVw0=
github.com/hashicorp/go-plugin v1.4.2/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
@ -434,6 +437,7 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p
github.com/hashicorp/memberlist v0.2.4/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
github.com/hashicorp/yamux v0.0.0-20210316155119-a95892c5f864 h1:Y4V+SFe7d3iH+9pJCoeWIOS5/xBJIFsltS7E+KJSsJY=
github.com/hashicorp/yamux v0.0.0-20210316155119-a95892c5f864/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
@ -474,6 +478,7 @@ github.com/jamiealquiza/envy v1.1.0/go.mod h1:MP36BriGCLwEHhi1OU8E9569JNZrjWfCvz
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU=
github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE=
github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
@ -584,6 +589,7 @@ github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaO
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
@ -592,6 +598,7 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
@ -627,6 +634,7 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
@ -1211,7 +1219,6 @@ golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -1427,7 +1434,6 @@ gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -60,7 +60,7 @@ func runServer(port int) (*server.Server, error) {
return nil, err
}
server, err := server.New(config, sessionToken, db, logger, "")
server, err := server.New(config, sessionToken, db, logger, "", nil)
if err != nil {
fmt.Println("ERROR INITIALIZING THE SERVER", err)
return nil, err

View File

@ -1,6 +1,6 @@
module github.com/mattermost/focalboard/mattermost-plugin
go 1.12
go 1.16
replace github.com/mattermost/focalboard/server => ../server

View File

@ -1225,7 +1225,6 @@ golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -1441,7 +1440,6 @@ gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -1,7 +1,7 @@
{
"id": "focalboard",
"name": "Focalboard",
"description": "This provides focalboard integration with mattermost.",
"name": "Mattermost Boards",
"description": "The Mattermost Boards plugin",
"homepage_url": "https://github.com/mattermost/focalboard",
"support_url": "https://github.com/mattermost/focalboard/issues",
"release_notes_url": "https://github.com/mattermost/focalboard/releases",

View File

@ -13,8 +13,8 @@ var manifest *model.Manifest
const manifestStr = `
{
"id": "focalboard",
"name": "Focalboard",
"description": "This provides focalboard integration with mattermost.",
"name": "Mattermost Boards",
"description": "The Mattermost Boards plugin",
"homepage_url": "https://github.com/mattermost/focalboard",
"support_url": "https://github.com/mattermost/focalboard/issues",
"release_notes_url": "https://github.com/mattermost/focalboard/releases",

View File

@ -6,15 +6,17 @@ import (
"path"
"sync"
"github.com/mattermost/focalboard/server/auth"
"github.com/mattermost/focalboard/server/server"
"github.com/mattermost/focalboard/server/services/config"
"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/ws"
pluginapi "github.com/mattermost/mattermost-plugin-api"
"github.com/mattermost/mattermost-server/v6/model"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
@ -30,30 +32,8 @@ type Plugin struct {
// setConfiguration for usage.
configuration *configuration
server *server.Server
wsHub *WSHub
}
type WSHub struct {
API plugin.API
handleWSMessage func(data []byte)
}
func (h *WSHub) SendWSMessage(data []byte) {
err := h.API.PublishPluginClusterEvent(model.PluginClusterEvent{
Id: "websocket_event",
Data: data,
}, model.PluginClusterEventSendOptions{
SendType: model.PluginClusterEventSendTypeReliable,
})
if err != nil {
h.API.LogError("Error sending websocket message", map[string]interface{}{"err": err})
}
}
func (h *WSHub) SetReceiveWSMessage(handler func(data []byte)) {
h.handleWSMessage = handler
server *server.Server
wsPluginAdapter *ws.PluginAdapter
}
func (p *Plugin) OnActivate() error {
@ -143,23 +123,28 @@ func (p *Plugin) OnActivate() error {
}
serverID := client.System.GetDiagnosticID()
p.wsPluginAdapter = ws.NewPluginAdapter(p.API, auth.New(cfg, db))
server, err := server.New(cfg, "", db, logger, serverID)
server, err := server.New(cfg, "", db, logger, serverID, p.wsPluginAdapter)
if err != nil {
fmt.Println("ERROR INITIALIZING THE SERVER", err)
return err
}
p.wsHub = &WSHub{API: p.API}
server.SetWSHub(p.wsHub)
p.server = server
return server.Start()
}
func (p *Plugin) OnPluginClusterEvent(_ *plugin.Context, ev model.PluginClusterEvent) {
if ev.Id == "websocket_event" {
p.wsHub.handleWSMessage(ev.Data)
}
func (p *Plugin) OnWebSocketConnect(webConnID, userID string) {
p.wsPluginAdapter.OnWebSocketConnect(webConnID, userID)
}
func (p *Plugin) OnWebSocketDisconnect(webConnID, userID string) {
p.wsPluginAdapter.OnWebSocketDisconnect(webConnID, userID)
}
func (p *Plugin) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mmModel.WebSocketRequest) {
p.wsPluginAdapter.WebSocketMessageHasBeenPosted(webConnID, userID, req)
}
func (p *Plugin) OnDeactivate() error {

View File

@ -18,6 +18,7 @@ import store from '../../../webapp/src/store'
import GlobalHeader from '../../../webapp/src/components/globalHeader/globalHeader'
import FocalboardIcon from '../../../webapp/src/widgets/icons/logo'
import {setMattermostTheme} from '../../../webapp/src/theme'
import wsClient, {MMWebSocketClient, ACTION_UPDATE_BLOCK} from './../../../webapp/src/wsclient'
import '../../../webapp/src/styles/focalboard-variables.scss'
import '../../../webapp/src/styles/main.scss'
@ -31,8 +32,13 @@ import {PluginRegistry} from './types/mattermost-webapp'
import './plugin.scss'
const MainApp = () => {
type Props = {
webSocketClient: MMWebSocketClient
}
const MainApp = (props: Props) => {
const [faviconStored, setFaviconStored] = useState(false)
wsClient.initPlugin(manifest.id, props.webSocketClient)
useEffect(() => {
document.body.classList.add('focalboard-body')
@ -143,12 +149,18 @@ export default class Plugin {
}, '', 'Boards')
this.registry.registerCustomRoute('/', MainApp)
}
// register websocket handlers
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BLOCK}`, (e: any) => wsClient.updateBlockHandler(e.data))
}
uninitialize(): void {
if (this.channelHeaderButtonId) {
this.registry?.unregisterComponent(this.channelHeaderButtonId)
}
// unregister websocket handlers
this.registry?.unregisterWebSocketEventHandler(wsClient.clientPrefix + ACTION_UPDATE_BLOCK)
}
}

View File

@ -9,6 +9,8 @@
z-index: 1000;
.Menu {
position: unset;
min-width: unset;
a,
button {
color: rgba(var(--center-channel-text-rgb), 1);

View File

@ -7,6 +7,8 @@ export interface PluginRegistry {
registerProductRoute(route: string, component: React.ElementType)
unregisterComponent(componentId: string)
registerProduct(baseURL: string, switcherIcon: string, switcherText: string, switcherLinkURL: string, mainComponent: React.ElementType, headerCompoent: React.ElementType)
registerWebSocketEventHandler(event: string, handler: (e: any) => void)
unregisterWebSocketEventHandler(event: string)
// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
}

View File

@ -2,22 +2,17 @@ package app
import (
"github.com/mattermost/focalboard/server/auth"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/services/config"
"github.com/mattermost/focalboard/server/services/metrics"
"github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/focalboard/server/services/webhook"
"github.com/mattermost/focalboard/server/ws"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
"github.com/mattermost/mattermost-server/v6/shared/filestore"
)
type WebsocketServer interface {
BroadcastBlockChange(workspaceID string, block model.Block)
BroadcastBlockDelete(workspaceID, blockID, parentID string)
}
type Services struct {
Auth *auth.Auth
Store store.Store
@ -31,19 +26,19 @@ type App struct {
config *config.Configuration
store store.Store
auth *auth.Auth
wsServer WebsocketServer
wsAdapter ws.Adapter
filesBackend filestore.FileBackend
webhook *webhook.Client
metrics *metrics.Metrics
logger *mlog.Logger
}
func New(config *config.Configuration, wsServer WebsocketServer, services Services) *App {
func New(config *config.Configuration, wsAdapter ws.Adapter, services Services) *App {
return &App{
config: config,
store: services.Store,
auth: services.Auth,
wsServer: wsServer,
wsAdapter: wsAdapter,
filesBackend: services.FilesBackend,
webhook: services.Webhook,
metrics: services.Metrics,

View File

@ -39,7 +39,7 @@ func (a *App) PatchBlock(c store.Container, blockID string, blockPatch *model.Bl
if err != nil {
return nil
}
a.wsServer.BroadcastBlockChange(c.WorkspaceID, *block)
a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, *block)
go a.webhook.NotifyUpdate(*block)
return nil
}
@ -59,7 +59,7 @@ func (a *App) InsertBlocks(c store.Container, blocks []model.Block, userID strin
return err
}
a.wsServer.BroadcastBlockChange(c.WorkspaceID, blocks[i])
a.wsAdapter.BroadcastBlockChange(c.WorkspaceID, blocks[i])
a.metrics.IncrementBlocksInserted(len(blocks))
go a.webhook.NotifyUpdate(blocks[i])
}
@ -90,7 +90,7 @@ func (a *App) DeleteBlock(c store.Container, blockID string, modifiedBy string)
return err
}
a.wsServer.BroadcastBlockDelete(c.WorkspaceID, blockID, parentID)
a.wsAdapter.BroadcastBlockDelete(c.WorkspaceID, blockID, parentID)
a.metrics.IncrementBlocksDeleted(1)
return nil

250
server/app/files_test.go Normal file
View File

@ -0,0 +1,250 @@
package app
import (
"io"
"strings"
"testing"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
"github.com/mattermost/mattermost-server/v6/shared/filestore"
"github.com/mattermost/mattermost-server/v6/shared/filestore/mocks"
"github.com/stretchr/testify/assert"
)
const (
testFileName = "temp-file-name"
testRootID = "test-root-id"
testFilePath = "1/test-root-id/temp-file-name"
)
type TestError struct{}
func (err *TestError) Error() string { return "Mocked File backend error" }
func TestGetFileReader(t *testing.T) {
th, _ := SetupTestHelper(t)
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
t.Run("should get file reader from filestore successfully", func(t *testing.T) {
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
readerFunc := func(path string) filestore.ReadCloseSeeker {
return mockedReadCloseSeek
}
readerErrorFunc := func(path string) error {
return nil
}
fileExistsFunc := func(path string) bool {
return true
}
fileExistsErrorFunc := func(path string) error {
return nil
}
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
actual, _ := th.App.GetFileReader("1", testRootID, testFileName)
assert.Equal(t, mockedReadCloseSeek, actual)
})
t.Run("should get error from filestore when file exists return error", func(t *testing.T) {
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedError := &TestError{}
readerFunc := func(path string) filestore.ReadCloseSeeker {
return mockedReadCloseSeek
}
readerErrorFunc := func(path string) error {
return nil
}
fileExistsFunc := func(path string) bool {
return false
}
fileExistsErrorFunc := func(path string) error {
return mockedError
}
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
actual, err := th.App.GetFileReader("1", testRootID, testFileName)
assert.Error(t, err, mockedError)
assert.Nil(t, actual)
})
t.Run("should return error, if get reader from file backend returns error", func(t *testing.T) {
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedError := &TestError{}
readerFunc := func(path string) filestore.ReadCloseSeeker {
return nil
}
readerErrorFunc := func(path string) error {
return mockedError
}
fileExistsFunc := func(path string) bool {
return false
}
fileExistsErrorFunc := func(path string) error {
return nil
}
mockedFileBackend.On("Reader", testFilePath).Return(readerFunc, readerErrorFunc)
mockedFileBackend.On("FileExists", testFilePath).Return(fileExistsFunc, fileExistsErrorFunc)
actual, err := th.App.GetFileReader("1", testRootID, testFileName)
assert.Error(t, err, mockedError)
assert.Nil(t, actual)
})
t.Run("should move file from old filepath to new filepath, if file doesnot exists in new filepath and workspace id is 0", func(t *testing.T) {
filePath := "0/test-root-id/temp-file-name"
workspaceid := "0"
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
readerFunc := func(path string) filestore.ReadCloseSeeker {
return mockedReadCloseSeek
}
readerErrorFunc := func(path string) error {
return nil
}
fileExistsFunc := func(path string) bool {
// return true for old path
return path == testFileName
}
fileExistsErrorFunc := func(path string) error {
return nil
}
moveFileFunc := func(oldFileName, newFileName string) error {
return nil
}
mockedFileBackend.On("FileExists", filePath).Return(fileExistsFunc, fileExistsErrorFunc)
mockedFileBackend.On("FileExists", testFileName).Return(fileExistsFunc, fileExistsErrorFunc)
mockedFileBackend.On("MoveFile", testFileName, filePath).Return(moveFileFunc)
mockedFileBackend.On("Reader", filePath).Return(readerFunc, readerErrorFunc)
actual, _ := th.App.GetFileReader(workspaceid, testRootID, testFileName)
assert.Equal(t, mockedReadCloseSeek, actual)
})
t.Run("should return file reader, if file doesnot exists in new filepath and old file path", func(t *testing.T) {
filePath := "0/test-root-id/temp-file-name"
fileName := testFileName
workspaceid := "0"
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
readerFunc := func(path string) filestore.ReadCloseSeeker {
return mockedReadCloseSeek
}
readerErrorFunc := func(path string) error {
return nil
}
fileExistsFunc := func(path string) bool {
// return true for old path
return false
}
fileExistsErrorFunc := func(path string) error {
return nil
}
moveFileFunc := func(oldFileName, newFileName string) error {
return nil
}
mockedFileBackend.On("FileExists", filePath).Return(fileExistsFunc, fileExistsErrorFunc)
mockedFileBackend.On("FileExists", testFileName).Return(fileExistsFunc, fileExistsErrorFunc)
mockedFileBackend.On("MoveFile", fileName, filePath).Return(moveFileFunc)
mockedFileBackend.On("Reader", filePath).Return(readerFunc, readerErrorFunc)
actual, _ := th.App.GetFileReader(workspaceid, testRootID, testFileName)
assert.Equal(t, mockedReadCloseSeek, actual)
})
}
func TestSaveFile(t *testing.T) {
th, _ := SetupTestHelper(t)
mockedReadCloseSeek := &mocks.ReadCloseSeeker{}
t.Run("should save file to file store using file backend", func(t *testing.T) {
fileName := "temp-file-name.txt"
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
writeFileFunc := func(reader io.Reader, path string) int64 {
paths := strings.Split(path, "/")
assert.Equal(t, "1", paths[0])
assert.Equal(t, testRootID, paths[1])
fileName = paths[2]
return int64(10)
}
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
return nil
}
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", testRootID, fileName)
assert.Equal(t, fileName, actual)
assert.Nil(t, err)
})
t.Run("should save .jpeg file as jpg file to file store using file backend", func(t *testing.T) {
fileName := "temp-file-name.jpeg"
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
writeFileFunc := func(reader io.Reader, path string) int64 {
paths := strings.Split(path, "/")
assert.Equal(t, "1", paths[0])
assert.Equal(t, "test-root-id", paths[1])
assert.Equal(t, "jpg", strings.Split(paths[2], ".")[1])
return int64(10)
}
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
return nil
}
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-root-id", fileName)
assert.Nil(t, err)
assert.NotNil(t, actual)
})
t.Run("should return error when fileBackend.WriteFile returns error", func(t *testing.T) {
fileName := "temp-file-name.jpeg"
mockedFileBackend := &mocks.FileBackend{}
th.App.filesBackend = mockedFileBackend
mockedError := &TestError{}
writeFileFunc := func(reader io.Reader, path string) int64 {
paths := strings.Split(path, "/")
assert.Equal(t, "1", paths[0])
assert.Equal(t, "test-root-id", paths[1])
assert.Equal(t, "jpg", strings.Split(paths[2], ".")[1])
return int64(10)
}
writeFileErrorFunc := func(reader io.Reader, filePath string) error {
return mockedError
}
mockedFileBackend.On("WriteFile", mockedReadCloseSeek, mock.Anything).Return(writeFileFunc, writeFileErrorFunc)
actual, err := th.App.SaveFile(mockedReadCloseSeek, "1", "test-root-id", fileName)
assert.Equal(t, "", actual)
assert.Equal(t, "unable to store the file in the files storage: Mocked File backend error", err.Error())
})
}

View File

@ -579,7 +579,6 @@ github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d h1:/RJ/UV7M5c7L2TQ
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d/go.mod h1:HLbgMEI5K131jpxGazJ97AxfPDt31osq36YS1oxFQPQ=
github.com/mattermost/logr v1.0.13 h1:6F/fM3csvH6Oy5sUpJuW7YyZSzZZAhJm5VcgKMxA2P8=
github.com/mattermost/logr v1.0.13/go.mod h1:Mt4DPu1NXMe6JxPdwCC0XBoxXmN9eXOIRPoZarU2PXs=
github.com/mattermost/logr/v2 v2.0.10 h1:i6rJbuX/EkBM9maM8M0eJ3rxB+fsBKNslPvzSlA2w/M=
github.com/mattermost/logr/v2 v2.0.10/go.mod h1:mpPp935r5dIkFDo2y9Q87cQWhFR/4xXpNh0k/y8Hmwg=
github.com/mattermost/logr/v2 v2.0.11 h1:eGg73t/HHkirGq34S+r6geGVuuVVrHDbW26QXCog6aw=
github.com/mattermost/logr/v2 v2.0.11/go.mod h1:mpPp935r5dIkFDo2y9Q87cQWhFR/4xXpNh0k/y8Hmwg=

View File

@ -75,7 +75,7 @@ func newTestServer(singleUserToken string) *server.Server {
if err != nil {
panic(err)
}
srv, err := server.New(cfg, singleUserToken, db, logger, "")
srv, err := server.New(cfg, singleUserToken, db, logger, "", nil)
if err != nil {
panic(err)
}

View File

@ -165,7 +165,7 @@ func main() {
logger.Fatal("server.NewStore ERROR", mlog.Err(err))
}
server, err := server.New(config, singleUserToken, db, logger, "")
server, err := server.New(config, singleUserToken, db, logger, "", nil)
if err != nil {
logger.Fatal("server.New ERROR", mlog.Err(err))
}
@ -245,7 +245,7 @@ func startServer(webPath string, filesPath string, port int, singleUserToken, db
logger.Fatal("server.NewStore ERROR", mlog.Err(err))
}
pServer, err = server.New(config, singleUserToken, db, logger, "")
pServer, err = server.New(config, singleUserToken, db, logger, "", nil)
if err != nil {
logger.Fatal("server.New ERROR", mlog.Err(err))
}

View File

@ -49,7 +49,7 @@ const (
type Server struct {
config *config.Configuration
wsServer *ws.Server
wsAdapter ws.Adapter
webServer *web.Server
store store.Store
filesBackend filestore.FileBackend
@ -67,10 +67,14 @@ type Server struct {
api *api.API
}
func New(cfg *config.Configuration, singleUserToken string, db store.Store, logger *mlog.Logger, serverID string) (*Server, error) {
func New(cfg *config.Configuration, singleUserToken string, db store.Store,
logger *mlog.Logger, serverID string, wsAdapter ws.Adapter) (*Server, error) {
authenticator := auth.New(cfg, db)
wsServer := ws.NewServer(authenticator, singleUserToken, cfg.AuthMode == MattermostAuthMod, logger)
// if no ws adapter is provided, we spin up a websocket server
if wsAdapter == nil {
wsAdapter = ws.NewServer(authenticator, singleUserToken, cfg.AuthMode == MattermostAuthMod, logger)
}
filesBackendSettings := filestore.FileBackendSettings{}
filesBackendSettings.DriverName = cfg.FilesDriver
@ -121,7 +125,7 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store, logg
Metrics: metricsService,
Logger: logger,
}
app := app.New(cfg, wsServer, appServices)
app := app.New(cfg, wsAdapter, appServices)
focalboardAPI := api.NewAPI(app, singleUserToken, cfg.AuthMode, logger, auditService)
@ -136,7 +140,10 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store, logg
}
webServer := web.NewServer(cfg.WebPath, cfg.ServerRoot, cfg.Port, cfg.UseSSL, cfg.LocalOnly, logger)
webServer.AddRoutes(wsServer)
// if the adapter is a routed service, register it before the API
if routedService, ok := wsAdapter.(web.RoutedService); ok {
webServer.AddRoutes(routedService)
}
webServer.AddRoutes(focalboardAPI)
settings, err := db.GetSystemSettings()
@ -164,7 +171,7 @@ func New(cfg *config.Configuration, singleUserToken string, db store.Store, logg
server := Server{
config: cfg,
wsServer: wsServer,
wsAdapter: wsAdapter,
webServer: webServer,
store: db,
filesBackend: filesBackend,
@ -364,10 +371,6 @@ func (s *Server) GetRootRouter() *mux.Router {
return s.webServer.Router()
}
func (s *Server) SetWSHub(hub ws.Hub) {
s.wsServer.SetHub(hub)
}
type telemetryOptions struct {
app *app.App
cfg *config.Configuration

View File

@ -0,0 +1,113 @@
package telemetry
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
"github.com/stretchr/testify/require"
)
func mockServer() (chan []byte, *httptest.Server) {
done := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := bytes.NewBuffer(nil)
if _, err := io.Copy(buf, r.Body); err != nil {
panic(err)
}
var v interface{}
err := json.Unmarshal(buf.Bytes(), &v)
if err != nil {
panic(err)
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
panic(err)
}
// filter the identify message
if strings.Contains(string(b), `"type": "identify"`) {
return
}
done <- b
}))
return done, server
}
func TestTelemetry(t *testing.T) {
receiveChan, server := mockServer()
os.Setenv("RUDDER_KEY", "mock-test-rudder-key")
os.Setenv("RUDDER_DATAPLANE_URL", server.URL)
checkMockRudderServer := func(t *testing.T) {
// check mock rudder server got
got := string(<-receiveChan)
require.Contains(t, got, "mockTrackerKey")
require.Contains(t, got, "mockTrackerValue")
}
t.Run("Register tracker and run telemetry job", func(t *testing.T) {
service := New("mockTelemetryID", mlog.CreateConsoleTestLogger(false, mlog.LvlDebug))
service.RegisterTracker("mockTracker", func() (Tracker, error) {
return map[string]interface{}{
"mockTrackerKey": "mockTrackerValue",
}, nil
})
service.RunTelemetryJob(time.Now().UnixNano() / int64(time.Millisecond))
checkMockRudderServer(t)
})
t.Run("do telemetry if needed", func(t *testing.T) {
service := New("mockTelemetryID", mlog.CreateConsoleTestLogger(false, mlog.LvlDebug))
service.RegisterTracker("mockTracker", func() (Tracker, error) {
return map[string]interface{}{
"mockTrackerKey": "mockTrackerValue",
}, nil
})
firstRun := time.Now()
t.Run("Send once every 10 minutes for the first hour", func(t *testing.T) {
service.doTelemetryIfNeeded(firstRun.Add(-30 * time.Minute))
checkMockRudderServer(t)
})
t.Run("Send once every hour thereafter for the first 12 hours", func(t *testing.T) {
// firstRun is 2 hours ago and timestampLastTelemetrySent is hour ago
// need to do telemetry
service.timestampLastTelemetrySent = time.Now().Add(-time.Hour)
service.doTelemetryIfNeeded(firstRun.Add(-2 * time.Hour))
checkMockRudderServer(t)
// firstRun is 2 hours ago and timestampLastTelemetrySent is just now
// no need to do telemetry
service.doTelemetryIfNeeded(firstRun.Add(-2 * time.Hour))
require.Equal(t, 0, len(receiveChan))
})
t.Run("Send at the 24 hour mark and every 24 hours after", func(t *testing.T) {
// firstRun is 24 hours ago and timestampLastTelemetrySent is 24 hours ago
// need to do telemetry
service.timestampLastTelemetrySent = time.Now().Add(-24 * time.Hour)
service.doTelemetryIfNeeded(firstRun.Add(-24 * time.Hour))
checkMockRudderServer(t)
// firstRun is 24 hours ago and timestampLastTelemetrySent is just now
// no need to do telemetry
service.doTelemetryIfNeeded(firstRun.Add(-24 * time.Hour))
require.Equal(t, 0, len(receiveChan))
})
})
}

19
server/ws/adapter.go Normal file
View File

@ -0,0 +1,19 @@
package ws
import (
"github.com/mattermost/focalboard/server/model"
)
const (
websocketActionAuth = "AUTH"
websocketActionSubscribeWorkspace = "SUBSCRIBE_WORKSPACE"
websocketActionUnsubscribeWorkspace = "UNSUBSCRIBE_WORKSPACE"
websocketActionSubscribeBlocks = "SUBSCRIBE_BLOCKS"
websocketActionUnsubscribeBlocks = "UNSUBSCRIBE_BLOCKS"
websocketActionUpdateBlock = "UPDATE_BLOCK"
)
type Adapter interface {
BroadcastBlockChange(workspaceID string, block model.Block)
BroadcastBlockDelete(workspaceID, blockID, parentID string)
}

314
server/ws/plugin_adapter.go Normal file
View File

@ -0,0 +1,314 @@
package ws
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/mattermost/focalboard/server/auth"
"github.com/mattermost/focalboard/server/model"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin"
)
const websocketMessagePrefix = "custom_focalboard_"
var errMissingWorkspaceInCommand = fmt.Errorf("command doesn't contain workspaceId")
func structToMap(v interface{}) (m map[string]interface{}) {
b, _ := json.Marshal(v)
_ = json.Unmarshal(b, &m)
return
}
type PluginAdapterClient struct {
webConnID string
userID string
workspaces []string
blocks []string
}
func (pac *PluginAdapterClient) isSubscribedToWorkspace(workspaceID string) bool {
for _, id := range pac.workspaces {
if id == workspaceID {
return true
}
}
return false
}
//nolint:unused
func (pac *PluginAdapterClient) isSubscribedToBlock(blockID string) bool {
for _, id := range pac.blocks {
if id == blockID {
return true
}
}
return false
}
type PluginAdapter struct {
api plugin.API
auth *auth.Auth
listeners map[string]*PluginAdapterClient
listenersByWorkspace map[string][]*PluginAdapterClient
listenersByBlock map[string][]*PluginAdapterClient
mu sync.RWMutex
}
func NewPluginAdapter(api plugin.API, auth *auth.Auth) *PluginAdapter {
return &PluginAdapter{
api: api,
auth: auth,
listeners: make(map[string]*PluginAdapterClient),
listenersByWorkspace: make(map[string][]*PluginAdapterClient),
listenersByBlock: make(map[string][]*PluginAdapterClient),
mu: sync.RWMutex{},
}
}
func (pa *PluginAdapter) addListener(pac *PluginAdapterClient) {
pa.mu.Lock()
defer pa.mu.Unlock()
pa.listeners[pac.webConnID] = pac
}
func (pa *PluginAdapter) removeListener(pac *PluginAdapterClient) {
pa.mu.Lock()
defer pa.mu.Unlock()
// workspace subscriptions
for _, workspace := range pac.workspaces {
pa.removeListenerFromWorkspace(pac, workspace)
}
// block subscriptions
for _, block := range pac.blocks {
pa.removeListenerFromBlock(pac, block)
}
delete(pa.listeners, pac.webConnID)
}
func (pa *PluginAdapter) removeListenerFromWorkspace(pac *PluginAdapterClient, workspaceID string) {
newWorkspaceListeners := []*PluginAdapterClient{}
for _, listener := range pa.listenersByWorkspace[workspaceID] {
if listener.webConnID != pac.webConnID {
newWorkspaceListeners = append(newWorkspaceListeners, listener)
}
}
pa.listenersByWorkspace[workspaceID] = newWorkspaceListeners
newClientWorkspaces := []string{}
for _, id := range pac.workspaces {
if id != workspaceID {
newClientWorkspaces = append(newClientWorkspaces, id)
}
}
pac.workspaces = newClientWorkspaces
}
func (pa *PluginAdapter) removeListenerFromBlock(pac *PluginAdapterClient, blockID string) {
newBlockListeners := []*PluginAdapterClient{}
for _, listener := range pa.listenersByBlock[blockID] {
if listener.webConnID != pac.webConnID {
newBlockListeners = append(newBlockListeners, listener)
}
}
pa.listenersByBlock[blockID] = newBlockListeners
newClientBlocks := []string{}
for _, id := range pac.blocks {
if id != blockID {
newClientBlocks = append(newClientBlocks, id)
}
}
pac.blocks = newClientBlocks
}
func (pa *PluginAdapter) subscribeListenerToWorkspace(pac *PluginAdapterClient, workspaceID string) {
if pac.isSubscribedToWorkspace(workspaceID) {
return
}
pa.mu.Lock()
defer pa.mu.Unlock()
pa.listenersByWorkspace[workspaceID] = append(pa.listenersByWorkspace[workspaceID], pac)
pac.workspaces = append(pac.workspaces, workspaceID)
}
func (pa *PluginAdapter) unsubscribeListenerFromWorkspace(pac *PluginAdapterClient, workspaceID string) {
if !pac.isSubscribedToWorkspace(workspaceID) {
return
}
pa.mu.Lock()
defer pa.mu.Unlock()
pa.removeListenerFromWorkspace(pac, workspaceID)
}
//nolint:unused
func (pa *PluginAdapter) unsubscribeListenerFromBlocks(pac *PluginAdapterClient, blockIDs []string) {
pa.mu.Lock()
defer pa.mu.Unlock()
for _, blockID := range blockIDs {
if pac.isSubscribedToBlock(blockID) {
pa.removeListenerFromBlock(pac, blockID)
}
}
}
func (pa *PluginAdapter) OnWebSocketConnect(webConnID, userID string) {
pac := &PluginAdapterClient{
webConnID: webConnID,
userID: userID,
workspaces: []string{},
blocks: []string{},
}
pa.addListener(pac)
}
func (pa *PluginAdapter) OnWebSocketDisconnect(webConnID, userID string) {
pac, ok := pa.listeners[webConnID]
if !ok {
pa.api.LogError("received a disconnect for an unregistered webconn",
"webConnID", webConnID,
"userID", userID,
)
return
}
pa.removeListener(pac)
}
func commandFromRequest(req *mmModel.WebSocketRequest) (*WebsocketCommand, error) {
c := &WebsocketCommand{Action: strings.TrimPrefix(req.Action, websocketMessagePrefix)}
if workspaceID, ok := req.Data["workspaceId"]; ok {
c.WorkspaceID = workspaceID.(string)
} else {
return nil, errMissingWorkspaceInCommand
}
if readToken, ok := req.Data["readToken"]; ok {
c.ReadToken = readToken.(string)
}
if blockIDs, ok := req.Data["blockIds"]; ok {
c.BlockIDs = blockIDs.([]string)
}
return c, nil
}
func (pa *PluginAdapter) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mmModel.WebSocketRequest) {
pac, ok := pa.listeners[webConnID]
if !ok {
pa.api.LogError("received a message for an unregistered webconn",
"webConnID", webConnID,
"userID", userID,
"action", req.Action,
)
return
}
// only process messages using the plugin actions
if !strings.HasPrefix(req.Action, websocketMessagePrefix) {
return
}
command, err := commandFromRequest(req)
if err != nil {
pa.api.LogError("error getting command from request",
"err", err,
"action", req.Action,
"webConnID", webConnID,
"userID", userID,
)
return
}
switch command.Action {
// The block-related commands are not implemented in the adapter
// as there is no such thing as unauthenticated websocket
// connections in plugin mode. Only a debug line is logged
case websocketActionSubscribeBlocks, websocketActionUnsubscribeBlocks:
pa.api.LogDebug(`Command not implemented in plugin mode`,
"command", command.Action,
"webConnID", webConnID,
"userID", userID,
"workspaceID", command.WorkspaceID,
)
case websocketActionSubscribeWorkspace:
pa.api.LogDebug(`Command: SUBSCRIBE_WORKSPACE`,
"webConnID", webConnID,
"userID", userID,
"workspaceID", command.WorkspaceID,
)
if !pa.auth.DoesUserHaveWorkspaceAccess(userID, command.WorkspaceID) {
return
}
pa.subscribeListenerToWorkspace(pac, command.WorkspaceID)
case websocketActionUnsubscribeWorkspace:
pa.api.LogDebug(`Command: UNSUBSCRIBE_WORKSPACE`,
"webConnID", webConnID,
"userID", userID,
"workspaceID", command.WorkspaceID,
)
pa.unsubscribeListenerFromWorkspace(pac, command.WorkspaceID)
}
}
func (pa *PluginAdapter) getUserIDsForWorkspace(workspaceID string) []string {
userMap := map[string]bool{}
for _, pac := range pa.listenersByWorkspace[workspaceID] {
userMap[pac.userID] = true
}
userIDs := []string{}
for userID := range userMap {
userIDs = append(userIDs, userID)
}
return userIDs
}
func (pa *PluginAdapter) BroadcastBlockChange(workspaceID string, block model.Block) {
pa.api.LogInfo("BroadcastingBlockChange",
"workspaceID", workspaceID,
"blockID", block.ID,
)
message := UpdateMsg{
Action: websocketActionUpdateBlock,
Block: block,
}
userIDs := pa.getUserIDsForWorkspace(workspaceID)
for _, userID := range userIDs {
pa.api.PublishWebSocketEvent(websocketActionUpdateBlock, structToMap(message), &mmModel.WebsocketBroadcast{UserId: userID})
}
}
func (pa *PluginAdapter) BroadcastBlockDelete(workspaceID, blockID, parentID string) {
now := time.Now().Unix()
block := model.Block{}
block.ID = blockID
block.ParentID = parentID
block.UpdateAt = now
block.DeleteAt = now
pa.BroadcastBlockChange(workspaceID, block)
}

View File

@ -2,7 +2,6 @@ package ws
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
@ -16,30 +15,18 @@ import (
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
const (
singleUserID = "single-user-id"
websocketActionAuth = "AUTH"
websocketActionSubscribeWorkspace = "SUBSCRIBE_WORKSPACE"
websocketActionUnsubscribeWorkspace = "UNSUBSCRIBE_WORKSPACE"
websocketActionSubscribeBlocks = "SUBSCRIBE_BLOCKS"
websocketActionUnsubscribeBlocks = "UNSUBSCRIBE_BLOCKS"
)
type Hub interface {
SendWSMessage(data []byte)
SetReceiveWSMessage(func(data []byte))
}
const singleUserID = "single-user-id"
type wsClient struct {
*websocket.Conn
lock *sync.Mutex
mu sync.Mutex
workspaces []string
blocks []string
}
func (c *wsClient) WriteJSON(v interface{}) error {
c.lock.Lock()
defer c.lock.Unlock()
c.mu.Lock()
defer c.mu.Unlock()
err := c.Conn.WriteJSON(v)
return err
}
@ -72,7 +59,6 @@ type Server struct {
listenersByBlock map[string][]*wsClient
mu sync.RWMutex
auth *auth.Auth
hub Hub
singleUserToken string
isMattermostAuth bool
logger *mlog.Logger
@ -84,13 +70,6 @@ type UpdateMsg struct {
Block model.Block `json:"block"`
}
// clusterUpdateMsg is sent on block updates.
type clusterUpdateMsg struct {
UpdateMsg
BlockID string `json:"block_id"`
WorkspaceID string `json:"workspace_id"`
}
// WebsocketCommand is an incoming command from the client.
type WebsocketCommand struct {
Action string `json:"action"`
@ -142,7 +121,7 @@ func (ws *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// create an empty session with websocket client
wsSession := websocketSession{
client: &wsClient{client, &sync.Mutex{}, []string{}, []string{}},
client: &wsClient{client, sync.Mutex{}, []string{}, []string{}},
userID: "",
}
@ -454,6 +433,10 @@ func (ws *Server) getUserIDForToken(token string) string {
}
func (ws *Server) authenticateListener(wsSession *websocketSession, token string) {
ws.logger.Debug("authenticateListener",
mlog.String("token", token),
mlog.String("wsSession.userID", wsSession.userID),
)
if wsSession.isAuthenticated() {
// Do not allow multiple auth calls (for security)
ws.logger.Debug(
@ -476,38 +459,6 @@ func (ws *Server) authenticateListener(wsSession *websocketSession, token string
ws.logger.Debug("authenticateListener: Authenticated", mlog.String("userID", userID), mlog.Stringer("client", wsSession.client.RemoteAddr()))
}
func (ws *Server) SetHub(hub Hub) {
ws.hub = hub
ws.hub.SetReceiveWSMessage(func(data []byte) {
var msg clusterUpdateMsg
err := json.Unmarshal(data, &msg)
if err != nil {
log.Printf("unable to unmarshal cluster message")
return
}
listeners := ws.getListenersForBlock(msg.BlockID)
log.Printf("%d listener(s) for blockID: %s", len(listeners), msg.BlockID)
listeners = append(listeners, ws.getListenersForWorkspace(msg.WorkspaceID)...)
log.Printf("%d listener(s) for workspaceID: %s", len(listeners), msg.WorkspaceID)
message := UpdateMsg{
Action: msg.Action,
Block: msg.Block,
}
for _, listener := range listeners {
log.Printf("Broadcast change, workspaceID: %s, blockID: %s, remoteAddr: %s", msg.WorkspaceID, msg.BlockID, listener.RemoteAddr())
err := listener.WriteJSON(message)
if err != nil {
log.Printf("broadcast error: %v", err)
listener.Close()
}
}
})
}
// getListenersForBlock returns the listeners subscribed to a
// block changes.
func (ws *Server) getListenersForBlock(blockID string) []*wsClient {
@ -537,7 +488,7 @@ func (ws *Server) BroadcastBlockChange(workspaceID string, block model.Block) {
blockIDsToNotify := []string{block.ID, block.ParentID}
message := UpdateMsg{
Action: "UPDATE_BLOCK",
Action: websocketActionUpdateBlock,
Block: block,
}
@ -553,14 +504,6 @@ func (ws *Server) BroadcastBlockChange(workspaceID string, block model.Block) {
mlog.Int("listener_count", len(listeners)),
mlog.String("blockID", blockID),
)
if ws.hub != nil {
data, err := json.Marshal(clusterUpdateMsg{UpdateMsg: message, WorkspaceID: workspaceID, BlockID: blockID})
if err != nil {
log.Printf("unable to serialize websocket message %v with the error: %v", message, err)
}
ws.hub.SendWSMessage(data)
}
}
for _, listener := range listeners {

View File

@ -14,7 +14,7 @@ import (
func TestWorkspaceSubscription(t *testing.T) {
server := NewServer(&auth.Auth{}, "token", false, &mlog.Logger{})
client := &wsClient{&websocket.Conn{}, &sync.Mutex{}, []string{}, []string{}}
client := &wsClient{&websocket.Conn{}, sync.Mutex{}, []string{}, []string{}}
session := &websocketSession{client: client}
workspaceID := "fake-workspace-id"
@ -100,7 +100,7 @@ func TestWorkspaceSubscription(t *testing.T) {
func TestBlocksSubscription(t *testing.T) {
server := NewServer(&auth.Auth{}, "token", false, &mlog.Logger{})
client := &wsClient{&websocket.Conn{}, &sync.Mutex{}, []string{}, []string{}}
client := &wsClient{&websocket.Conn{}, sync.Mutex{}, []string{}, []string{}}
session := &websocketSession{client: client}
blockID1 := "block1"
blockID2 := "block2"

View File

@ -15,6 +15,7 @@
"CardDetail.new-comment-placeholder": "Ajouter un commentaire...",
"CardDialog.editing-template": "Vous éditez un modèle",
"CardDialog.nocard": "Cette carte n'existe pas ou n'est pas accessible",
"ColorOption.selectColor": "Choisir la couleur {color}",
"Comment.delete": "Supprimer",
"CommentsList.send": "Envoyer",
"ContentBlock.Delete": "Supprimer",
@ -71,7 +72,7 @@
"PropertyType.Select": "Liste",
"PropertyType.Text": "Texte",
"PropertyType.URL": "URL",
"PropertyType.UpdatedBy": "Mis à jour par",
"PropertyType.UpdatedBy": "Mis à jour dernièrement par",
"PropertyType.UpdatedTime": "Date de dernière mise à jour",
"RegistrationLink.confirmRegenerateToken": "Ceci va désactiver les liens de partages existants. Continuer ?",
"RegistrationLink.copiedLink": "Copié !",
@ -88,7 +89,7 @@
"ShareBoard.unshare": "N'importe qui disposant du lien peut accéder à ce tableau et à toutes les cartes dedans",
"Sidebar.about": "A propos de Focalboard",
"Sidebar.add-board": "+ Ajouter un tableau",
"Sidebar.add-template": "+ Nouveau modèle",
"Sidebar.add-template": "Nouveau modèle",
"Sidebar.changePassword": "Modifier le mot de passe",
"Sidebar.delete-board": "Supprimer le tableau",
"Sidebar.delete-template": "Supprimer",
@ -120,6 +121,8 @@
"TableHeaderMenu.sort-ascending": "Tri ascendant",
"TableHeaderMenu.sort-descending": "Tri descendant",
"TableRow.open": "Ouvrir",
"ValueSelector.valueSelector": "Sélecteur de value",
"ValueSelectorLabel.openMenu": "Ouvrir le menu",
"View.AddView": "Ajouter une vue",
"View.Board": "Tableau",
"View.DeleteView": "Supprimer la vue",
@ -145,7 +148,7 @@
"ViewHeader.share-board": "Partager le tableau",
"ViewHeader.sort": "Trier",
"ViewHeader.untitled": "Sans titre",
"ViewTitle.hide-description": "Cacher la description",
"ViewTitle.hide-description": "cacher la description",
"ViewTitle.pick-icon": "Choisir une icône",
"ViewTitle.random-icon": "Aléatoire",
"ViewTitle.remove-icon": "Supprimer l'icône",

View File

@ -15,6 +15,7 @@
"CardDetail.new-comment-placeholder": "Apondre un comentari...",
"CardDialog.editing-template": "Sètz a modificar un modèl",
"CardDialog.nocard": "Aquesta zòna existís pas o es pas accessibla",
"ColorOption.selectColor": "Seleccionar la color {color}",
"Comment.delete": "Suprimir",
"CommentsList.send": "Enviar",
"ContentBlock.Delete": "Suprimir",
@ -120,6 +121,8 @@
"TableHeaderMenu.sort-ascending": "Tria ascendenta",
"TableHeaderMenu.sort-descending": "Tria descendenta",
"TableRow.open": "Dobrir",
"ValueSelector.valueSelector": "Selector de valor",
"ValueSelectorLabel.openMenu": "Dobrir lo menú",
"View.AddView": "Apondre una vista",
"View.Board": "Tablèu",
"View.DeleteView": "Suprimir la vista",

View File

@ -18,6 +18,7 @@ interface BlockPatch {
deletedFields?: string[]
deleteAt?: number
}
interface Block {
id: string
parentId: string

View File

@ -0,0 +1,115 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/addContentMenuItem return a checkbox menu item 1`] = `
<div>
<div
aria-label="checkbox"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="CheckIcon Icon"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="20,60 40,80 80,40"
/>
</svg>
<div
class="menu-name"
>
checkbox
</div>
<div
class="noicon"
/>
</div>
</div>
`;
exports[`components/addContentMenuItem return a divider menu item 1`] = `
<div>
<div
aria-label="divider"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DividerIcon Icon"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M 432,224 H 16 c -8.836556,0 -16,7.16344 -16,16 v 32 c 0,8.83656 7.163444,16 16,16 h 416 c 8.83656,0 16,-7.16344 16,-16 v -32 c 0,-8.83656 -7.16344,-16 -16,-16 z"
/>
</svg>
<div
class="menu-name"
>
divider
</div>
<div
class="noicon"
/>
</div>
</div>
`;
exports[`components/addContentMenuItem return a text menu item 1`] = `
<div>
<div
aria-label="text"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="TextIcon Icon"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M432 416H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-128H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-128H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-128H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
/>
</svg>
<div
class="menu-name"
>
text
</div>
<div
class="noicon"
/>
</div>
</div>
`;
exports[`components/addContentMenuItem return an error and empty element from unknow type 1`] = `<div />`;
exports[`components/addContentMenuItem return an image menu item 1`] = `
<div>
<div
aria-label="image"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="ImageIcon Icon"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M464 64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V112c0-26.51-21.49-48-48-48zm-6 336H54a6 6 0 0 1-6-6V118a6 6 0 0 1 6-6h404a6 6 0 0 1 6 6v276a6 6 0 0 1-6 6zM128 152c-22.091 0-40 17.909-40 40s17.909 40 40 40 40-17.909 40-40-17.909-40-40-40zM96 352h320v-80l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L192 304l-39.515-39.515c-4.686-4.686-12.284-4.686-16.971 0L96 304v48z"
/>
</svg>
<div
class="menu-name"
>
image
</div>
<div
class="noicon"
/>
</div>
</div>
`;

View File

@ -0,0 +1,177 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/blockIconSelector return an icon correctly 1`] = `
<div>
<div
class="BlockIconSelector"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="octo-icon size-l"
>
<span>
👍
</span>
</div>
</div>
</div>
</div>
`;
exports[`components/blockIconSelector return menu on click 1`] = `
<div>
<div
class="BlockIconSelector"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="octo-icon size-l"
>
<span>
👍
</span>
</div>
<div
class="Menu noselect bottom"
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div
aria-label="Random"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="EmojiIcon Icon"
viewBox="0 0 496 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-110.3 0-200-89.7-200-200S137.7 56 248 56s200 89.7 200 200-89.7 200-200 200zm-80-216c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm4 72.6c-20.8 25-51.5 39.4-84 39.4s-63.2-14.3-84-39.4c-8.5-10.2-23.7-11.5-33.8-3.1-10.2 8.5-11.5 23.6-3.1 33.8 30 36 74.1 56.6 120.9 56.6s90.9-20.6 120.9-56.6c8.5-10.2 7.1-25.3-3.1-33.8-10.1-8.4-25.3-7.1-33.8 3.1z"
/>
</svg>
<div
class="menu-name"
>
Random
</div>
<div
class="noicon"
/>
</div>
<div
class="MenuOption SubMenuOption menu-option"
id="pick"
>
<svg
class="EmojiIcon Icon"
viewBox="0 0 496 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-110.3 0-200-89.7-200-200S137.7 56 248 56s200 89.7 200 200-89.7 200-200 200zm-80-216c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm4 72.6c-20.8 25-51.5 39.4-84 39.4s-63.2-14.3-84-39.4c-8.5-10.2-23.7-11.5-33.8-3.1-10.2 8.5-11.5 23.6-3.1 33.8 30 36 74.1 56.6 120.9 56.6s90.9-20.6 120.9-56.6c8.5-10.2 7.1-25.3-3.1-33.8-10.1-8.4-25.3-7.1-33.8 3.1z"
/>
</svg>
<div
class="menu-name"
>
Pick icon
</div>
<svg
class="SubmenuTriangleIcon Icon"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="50,35 75,50 50,65"
/>
</svg>
</div>
<div
aria-label="Remove icon"
class="MenuOption TextOption menu-option"
role="button"
>
<svg
class="DeleteIcon Icon"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M268 416h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12zM432 80h-82.41l-34-56.7A48 48 0 0 0 274.41 0H173.59a48 48 0 0 0-41.16 23.3L98.41 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16v336a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM171.84 50.91A6 6 0 0 1 177 48h94a6 6 0 0 1 5.15 2.91L293.61 80H154.39zM368 464H80V128h288zm-212-48h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12z"
/>
</svg>
<div
class="menu-name"
>
Remove icon
</div>
<div
class="noicon"
/>
</div>
</div>
<div
class="menu-spacer hideOnWidescreen"
/>
<div
class="menu-options hideOnWidescreen"
>
<div
aria-label="Cancel"
class="MenuOption TextOption menu-option menu-cancel"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Cancel
</div>
<div
class="noicon"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`components/blockIconSelector return no element with no icon 1`] = `<div />`;
exports[`components/blockIconSelector return no menu in readonly 1`] = `
<div>
<div
class="BlockIconSelector"
>
<div
class="octo-icon size-m readonly"
>
<span>
👍
</span>
</div>
</div>
</div>
`;
exports[`components/blockIconSelector return no icon after click on remove menu 1`] = `<div />`;

View File

@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactElement} from 'react'
import {render, screen, waitFor} from '@testing-library/react'
import '@testing-library/jest-dom'
import {IntlProvider} from 'react-intl'
import {mocked} from 'ts-jest/utils'
import userEvent from '@testing-library/user-event'
import mutator from '../mutator'
import {TestBlockFactory} from '../test/testBlockFactory'
import AddContentMenuItem from './addContentMenuItem'
import './content/textElement'
import './content/imageElement'
import './content/dividerElement'
import './content/checkboxElement'
const wrapIntl = (children: ReactElement) => (
<IntlProvider locale='en'>{children}</IntlProvider>
)
const board = TestBlockFactory.createBoard()
const card = TestBlockFactory.createCard(board)
jest.mock('../mutator')
const mockedMutator = mocked(mutator, true)
describe('components/addContentMenuItem', () => {
beforeEach(() => {
jest.clearAllMocks()
})
test('return an image menu item', () => {
const {container} = render(
wrapIntl(
<AddContentMenuItem
type={'image'}
card={card}
cords={{x: 0}}
/>,
),
)
expect(container).toMatchSnapshot()
})
test('return a text menu item', async () => {
const {container} = render(
wrapIntl(
<AddContentMenuItem
type={'text'}
card={card}
cords={{x: 0}}
/>,
),
)
expect(container).toMatchSnapshot()
const buttonElement = screen.getByRole('button', {name: 'text'})
userEvent.click(buttonElement)
await waitFor(() => expect(mockedMutator.performAsUndoGroup).toBeCalled())
})
test('return a checkbox menu item', async () => {
const {container} = render(
wrapIntl(
<AddContentMenuItem
type={'checkbox'}
card={card}
cords={{x: 0}}
/>,
),
)
expect(container).toMatchSnapshot()
const buttonElement = screen.getByRole('button', {name: 'checkbox'})
userEvent.click(buttonElement)
await waitFor(() => expect(mockedMutator.performAsUndoGroup).toBeCalled())
})
test('return a divider menu item', async () => {
const {container} = render(
wrapIntl(
<AddContentMenuItem
type={'divider'}
card={card}
cords={{x: 0}}
/>,
),
)
expect(container).toMatchSnapshot()
const buttonElement = screen.getByRole('button', {name: 'divider'})
userEvent.click(buttonElement)
await waitFor(() => expect(mockedMutator.performAsUndoGroup).toBeCalled())
})
test('return an error and empty element from unknow type', () => {
const {container} = render(
wrapIntl(
<AddContentMenuItem
type={'unknown'}
card={card}
cords={{x: 0}}
/>,
),
)
expect(container).toMatchSnapshot()
})
})

View File

@ -0,0 +1,127 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactElement} from 'react'
import {fireEvent, render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom'
import {IntlProvider} from 'react-intl'
import {mocked} from 'ts-jest/utils'
import mutator from '../mutator'
import {TestBlockFactory} from '../test/testBlockFactory'
import BlockIconSelector from './blockIconSelector'
const wrapIntl = (children: ReactElement) => <IntlProvider locale='en'>{children}</IntlProvider>
const board = TestBlockFactory.createBoard()
const icon = '👍'
jest.mock('../mutator')
const mockedMutator = mocked(mutator, true)
describe('components/blockIconSelector', () => {
beforeEach(() => {
board.fields.icon = icon
jest.clearAllMocks()
})
test('return an icon correctly', () => {
const {container} = render(wrapIntl(
<BlockIconSelector
block={board}
size='l'
/>,
))
expect(container).toMatchSnapshot()
})
test('return no element with no icon', () => {
board.fields.icon = ''
const {container} = render(wrapIntl(
<BlockIconSelector
block={board}
size='l'
/>,
))
expect(container).toMatchSnapshot()
})
test('return menu on click', () => {
const {container} = render(wrapIntl(
<BlockIconSelector
block={board}
size='l'
/>,
))
userEvent.click(screen.getByRole('button', {name: 'menuwrapper'}))
expect(container).toMatchSnapshot()
})
test('return no menu in readonly', () => {
const {container} = render(wrapIntl(
<BlockIconSelector
block={board}
readonly={true}
/>,
))
expect(container).toMatchSnapshot()
})
test('return a new icon after click on random menu', () => {
render(wrapIntl(
<BlockIconSelector
block={board}
size='l'
/>,
))
userEvent.click(screen.getByRole('button', {name: 'menuwrapper'}))
const buttonRandom = screen.queryByRole('button', {name: 'Random'})
expect(buttonRandom).not.toBeNull()
userEvent.click(buttonRandom!)
expect(mockedMutator.changeIcon).toBeCalledTimes(1)
})
test('return a new icon after click on EmojiPicker', async () => {
const {container} = render(wrapIntl(
<BlockIconSelector
block={board}
size='l'
/>,
))
userEvent.click(screen.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})
userEvent.click(allButtonThumbUp[0])
expect(mockedMutator.changeIcon).toBeCalledTimes(1)
expect(mockedMutator.changeIcon).toBeCalledWith(board.id, board.fields.icon, '👍')
})
test('return no icon after click on remove menu', () => {
const {container, rerender} = render(wrapIntl(
<BlockIconSelector
block={board}
size='l'
/>,
))
userEvent.click(screen.getByRole('button', {name: 'menuwrapper'}))
const buttonRemove = screen.queryByRole('button', {name: 'Remove icon'})
expect(buttonRemove).not.toBeNull()
userEvent.click(buttonRemove!)
expect(mockedMutator.changeIcon).toBeCalledTimes(1)
expect(mockedMutator.changeIcon).toBeCalledWith(board.id, board.fields.icon, '', 'remove icon')
//simulate reset icon
board.fields.icon = ''
rerender(wrapIntl(
<BlockIconSelector
block={board}
/>),
)
expect(container).toMatchSnapshot()
})
})

View File

@ -3,7 +3,7 @@
exports[`components/calculations/Calculation should match snapshot - count 1`] = `
<div>
<div
class="Calculation count fooClass"
class="Calculation count fooClass hovered"
tabindex="0"
>
<span
@ -23,7 +23,7 @@ exports[`components/calculations/Calculation should match snapshot - count 1`] =
exports[`components/calculations/Calculation should match snapshot - countUniqueValue 1`] = `
<div>
<div
class="Calculation countUniqueValue fooClass"
class="Calculation countUniqueValue fooClass hovered"
tabindex="0"
>
<span
@ -43,7 +43,7 @@ exports[`components/calculations/Calculation should match snapshot - countUnique
exports[`components/calculations/Calculation should match snapshot - countValue 1`] = `
<div>
<div
class="Calculation countValue fooClass"
class="Calculation countValue fooClass hovered"
tabindex="0"
>
<span
@ -63,7 +63,7 @@ exports[`components/calculations/Calculation should match snapshot - countValue
exports[`components/calculations/Calculation should match snapshot - none 1`] = `
<div>
<div
class="Calculation none fooClass"
class="Calculation none fooClass hovered"
tabindex="0"
>
<span
@ -81,7 +81,7 @@ exports[`components/calculations/Calculation should match snapshot - none 1`] =
exports[`components/calculations/Calculation should match snapshot - option change 1`] = `
<div>
<div
class="Calculation none fooClass"
class="Calculation none fooClass menuOpen hovered"
tabindex="0"
>
<div>

View File

@ -3,9 +3,10 @@
.Calculation {
cursor: pointer;
transition: opacity 0.1s ease-in;
&.none {
color: rgba(var(--body-color), 0.64);
opacity: 0;
.calculationLabel {
text-transform: capitalize;
@ -13,6 +14,14 @@
font-size: 14px;
margin-right: 0;
}
&.hovered {
opacity: 0.64;
}
&.menuOpen {
opacity: 1;
}
}
.calculationLabel {

View File

@ -34,6 +34,7 @@ describe('components/calculations/Calculation', () => {
onMenuOpen={() => {}}
onChange={() => {}}
cards={[card, card2]}
hovered={true}
property={{
id: 'property_2',
name: '',
@ -58,6 +59,7 @@ describe('components/calculations/Calculation', () => {
onMenuOpen={() => {}}
onChange={() => {}}
cards={[card, card2]}
hovered={true}
property={{
id: 'property_2',
name: '',
@ -82,6 +84,7 @@ describe('components/calculations/Calculation', () => {
onMenuOpen={() => {}}
onChange={() => {}}
cards={[card, card2]}
hovered={true}
property={{
id: 'property_3',
name: '',
@ -106,6 +109,7 @@ describe('components/calculations/Calculation', () => {
onMenuOpen={() => {}}
onChange={() => {}}
cards={[card, card2]}
hovered={true}
property={{
id: 'property_4',
name: '',
@ -134,6 +138,7 @@ describe('components/calculations/Calculation', () => {
onMenuOpen={onMenuOpen}
onChange={onChange}
cards={[card, card2]}
hovered={true}
property={{
id: 'property_2',
name: '',

View File

@ -23,6 +23,7 @@ type Props = {
onChange: (value: string) => void
cards: readonly Card[]
property: IPropertyTemplate
hovered: boolean
}
const Calculation = (props: Props): JSX.Element => {
@ -35,7 +36,7 @@ const Calculation = (props: Props): JSX.Element => {
// See this for more details-
// https://stackoverflow.com/questions/47308081/onblur-event-is-not-firing
<div
className={`Calculation ${value} ${props.class}`}
className={`Calculation ${value} ${props.class} ${props.menuOpen ? 'menuOpen' : ''} ${props.hovered ? 'hovered' : ''}`}
style={props.style}
onClick={() => (props.menuOpen ? props.onMenuClose() : props.onMenuOpen())}
tabIndex={0}

View File

@ -164,7 +164,9 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="octo-block-margin"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -226,7 +228,9 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="octo-block-margin"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -279,7 +283,9 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="octo-block-margin"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -347,7 +353,9 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="octo-block-margin"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -405,7 +413,9 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="octo-block-margin"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -476,7 +486,9 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="octo-block-margin"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -534,7 +546,9 @@ exports[`components/cardDetail/cardDetailContents should match snapshot after dr
class="octo-block-margin"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -755,7 +769,9 @@ exports[`components/cardDetail/cardDetailContents should match snapshot with con
class="octo-block-margin"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -826,7 +842,9 @@ exports[`components/cardDetail/cardDetailContents should match snapshot with con
class="octo-block-margin"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -888,7 +906,9 @@ exports[`components/cardDetail/cardDetailContents should match snapshot with con
class="octo-block-margin"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -941,7 +961,9 @@ exports[`components/cardDetail/cardDetailContents should match snapshot with con
class="octo-block-margin"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"

View File

@ -9,7 +9,9 @@ exports[`components/cardDetail/CardDetailProperties should match snapshot 1`] =
class="octo-propertyrow"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="octo-propertyname"

View File

@ -22,7 +22,9 @@ exports[`components/sidebar/GlobalHeader header menu should match snapshot 1`] =
class="GlobalHeaderSettingsMenu"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="GlobalHeaderComponent__button menu-entry"

View File

@ -6,7 +6,9 @@ exports[`components/sidebar/GlobalHeaderSettingsMenu languages menu open should
class="GlobalHeaderSettingsMenu"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="GlobalHeaderComponent__button menu-entry"
@ -366,7 +368,9 @@ exports[`components/sidebar/GlobalHeaderSettingsMenu settings menu closed should
class="GlobalHeaderSettingsMenu"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="GlobalHeaderComponent__button menu-entry"
@ -386,7 +390,9 @@ exports[`components/sidebar/GlobalHeaderSettingsMenu settings menu open should m
class="GlobalHeaderSettingsMenu"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="GlobalHeaderComponent__button menu-entry"

View File

@ -6,7 +6,9 @@ exports[`components/sidebar/SidebarSettingsMenu languages menu open should match
class="SidebarSettingsMenu"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="menu-entry"
@ -386,7 +388,9 @@ exports[`components/sidebar/SidebarSettingsMenu settings menu closed should matc
class="SidebarSettingsMenu"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="menu-entry"
@ -404,7 +408,9 @@ exports[`components/sidebar/SidebarSettingsMenu settings menu open should match
class="SidebarSettingsMenu"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="menu-entry"
@ -555,7 +561,9 @@ exports[`components/sidebar/SidebarSettingsMenu theme menu open should match sna
class="SidebarSettingsMenu"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="menu-entry"

View File

@ -17,7 +17,9 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -39,7 +41,9 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -73,7 +77,9 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -107,7 +113,9 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -333,7 +341,23 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 1`
class="CalculationRow octo-table-row"
>
<div
class="Calculation none octo-table-cell"
class="Calculation count octo-table-cell "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Count
</span>
<span
class="calculationValue"
>
2
</span>
</div>
<div
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -347,7 +371,7 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 1`
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -361,21 +385,7 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 1`
/>
</div>
<div
class="Calculation none octo-table-cell"
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Calculate
</span>
<i
class="CompassIcon icon-chevron-up ChevronUpIcon"
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -411,7 +421,9 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 2`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -433,7 +445,9 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 2`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -467,7 +481,9 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 2`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -501,7 +517,9 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 2`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -727,7 +745,23 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 2`
class="CalculationRow octo-table-row"
>
<div
class="Calculation none octo-table-cell"
class="Calculation count octo-table-cell "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Count
</span>
<span
class="calculationValue"
>
2
</span>
</div>
<div
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -741,7 +775,7 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 2`
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -755,21 +789,7 @@ exports[`components/table/Table extended should match snapshot with CreatedBy 2`
/>
</div>
<div
class="Calculation none octo-table-cell"
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Calculate
</span>
<i
class="CompassIcon icon-chevron-up ChevronUpIcon"
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -805,7 +825,9 @@ exports[`components/table/Table extended should match snapshot with UpdatedAt 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -827,7 +849,9 @@ exports[`components/table/Table extended should match snapshot with UpdatedAt 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -861,7 +885,9 @@ exports[`components/table/Table extended should match snapshot with UpdatedAt 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -895,7 +921,9 @@ exports[`components/table/Table extended should match snapshot with UpdatedAt 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -1121,7 +1149,23 @@ exports[`components/table/Table extended should match snapshot with UpdatedAt 1`
class="CalculationRow octo-table-row"
>
<div
class="Calculation none octo-table-cell"
class="Calculation count octo-table-cell "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Count
</span>
<span
class="calculationValue"
>
2
</span>
</div>
<div
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -1135,7 +1179,7 @@ exports[`components/table/Table extended should match snapshot with UpdatedAt 1`
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -1149,21 +1193,7 @@ exports[`components/table/Table extended should match snapshot with UpdatedAt 1`
/>
</div>
<div
class="Calculation none octo-table-cell"
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Calculate
</span>
<i
class="CompassIcon icon-chevron-up ChevronUpIcon"
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -1199,7 +1229,9 @@ exports[`components/table/Table extended should match snapshot with UpdatedBy 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -1221,7 +1253,9 @@ exports[`components/table/Table extended should match snapshot with UpdatedBy 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -1255,7 +1289,9 @@ exports[`components/table/Table extended should match snapshot with UpdatedBy 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -1289,7 +1325,9 @@ exports[`components/table/Table extended should match snapshot with UpdatedBy 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -1515,7 +1553,23 @@ exports[`components/table/Table extended should match snapshot with UpdatedBy 1`
class="CalculationRow octo-table-row"
>
<div
class="Calculation none octo-table-cell"
class="Calculation count octo-table-cell "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Count
</span>
<span
class="calculationValue"
>
2
</span>
</div>
<div
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -1529,7 +1583,7 @@ exports[`components/table/Table extended should match snapshot with UpdatedBy 1`
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -1543,21 +1597,7 @@ exports[`components/table/Table extended should match snapshot with UpdatedBy 1`
/>
</div>
<div
class="Calculation none octo-table-cell"
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Calculate
</span>
<i
class="CompassIcon icon-chevron-up ChevronUpIcon"
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -1593,7 +1633,9 @@ exports[`components/table/Table should match snapshot 1`] = `
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -1615,7 +1657,9 @@ exports[`components/table/Table should match snapshot 1`] = `
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -1649,7 +1693,9 @@ exports[`components/table/Table should match snapshot 1`] = `
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -1781,7 +1827,23 @@ exports[`components/table/Table should match snapshot 1`] = `
class="CalculationRow octo-table-row"
>
<div
class="Calculation none octo-table-cell"
class="Calculation count octo-table-cell "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Count
</span>
<span
class="calculationValue"
>
1
</span>
</div>
<div
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -1795,21 +1857,7 @@ exports[`components/table/Table should match snapshot 1`] = `
/>
</div>
<div
class="Calculation none octo-table-cell"
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Calculate
</span>
<i
class="CompassIcon icon-chevron-up ChevronUpIcon"
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -1845,7 +1893,9 @@ exports[`components/table/Table should match snapshot with GroupBy 1`] = `
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -1867,7 +1917,9 @@ exports[`components/table/Table should match snapshot with GroupBy 1`] = `
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -1901,7 +1953,9 @@ exports[`components/table/Table should match snapshot with GroupBy 1`] = `
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "
@ -1976,7 +2030,9 @@ exports[`components/table/Table should match snapshot with GroupBy 1`] = `
</span>
</button>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -2005,7 +2061,23 @@ exports[`components/table/Table should match snapshot with GroupBy 1`] = `
class="CalculationRow octo-table-row"
>
<div
class="Calculation none octo-table-cell"
class="Calculation count octo-table-cell "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Count
</span>
<span
class="calculationValue"
>
1
</span>
</div>
<div
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -2019,21 +2091,7 @@ exports[`components/table/Table should match snapshot with GroupBy 1`] = `
/>
</div>
<div
class="Calculation none octo-table-cell"
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Calculate
</span>
<i
class="CompassIcon icon-chevron-up ChevronUpIcon"
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -2069,7 +2127,9 @@ exports[`components/table/Table should match snapshot, read-only 1`] = `
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper disabled"
role="button"
>
<span
class="Label empty "
@ -2087,7 +2147,9 @@ exports[`components/table/Table should match snapshot, read-only 1`] = `
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper disabled"
role="button"
>
<span
class="Label empty "
@ -2117,7 +2179,9 @@ exports[`components/table/Table should match snapshot, read-only 1`] = `
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper disabled"
role="button"
>
<span
class="Label empty "
@ -2230,7 +2294,23 @@ exports[`components/table/Table should match snapshot, read-only 1`] = `
class="CalculationRow octo-table-row"
>
<div
class="Calculation none octo-table-cell"
class="Calculation count octo-table-cell "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Count
</span>
<span
class="calculationValue"
>
1
</span>
</div>
<div
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -2244,21 +2324,7 @@ exports[`components/table/Table should match snapshot, read-only 1`] = `
/>
</div>
<div
class="Calculation none octo-table-cell"
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Calculate
</span>
<i
class="CompassIcon icon-chevron-up ChevronUpIcon"
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>

View File

@ -96,7 +96,9 @@ exports[`should match snapshot with Group 1`] = `
</span>
</button>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -165,7 +167,9 @@ exports[`should match snapshot, add new 1`] = `
</span>
</button>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -234,7 +238,9 @@ exports[`should match snapshot, edit title 1`] = `
</span>
</button>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -303,7 +309,9 @@ exports[`should match snapshot, hide group 1`] = `
</span>
</button>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
@ -368,7 +376,9 @@ exports[`should match snapshot, no groups 1`] = `
</span>
</button>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"

View File

@ -8,7 +8,9 @@ exports[`components/table/TableHeaderMenu should match snapshot, title column 1`
style="overflow: unset; width: 100px; opacity: 1;"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<span
class="Label empty "

View File

@ -6,21 +6,7 @@ exports[`components/table/calculation/CalculationRow should match snapshot 1`] =
class="CalculationRow octo-table-row"
>
<div
class="Calculation none octo-table-cell"
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Calculate
</span>
<i
class="CompassIcon icon-chevron-up ChevronUpIcon"
/>
</div>
<div
class="Calculation count octo-table-cell"
class="Calculation count octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -36,7 +22,23 @@ exports[`components/table/calculation/CalculationRow should match snapshot 1`] =
</span>
</div>
<div
class="Calculation countValue octo-table-cell"
class="Calculation count octo-table-cell "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Count
</span>
<span
class="calculationValue"
>
2
</span>
</div>
<div
class="Calculation countValue octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -52,7 +54,7 @@ exports[`components/table/calculation/CalculationRow should match snapshot 1`] =
</span>
</div>
<div
class="Calculation countUniqueValue octo-table-cell"
class="Calculation countUniqueValue octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -77,7 +79,23 @@ exports[`components/table/calculation/CalculationRow should render three calcula
class="CalculationRow octo-table-row"
>
<div
class="Calculation none octo-table-cell"
class="Calculation count octo-table-cell "
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Count
</span>
<span
class="calculationValue"
>
2
</span>
</div>
<div
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -91,7 +109,7 @@ exports[`components/table/calculation/CalculationRow should render three calcula
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>
@ -105,21 +123,7 @@ exports[`components/table/calculation/CalculationRow should render three calcula
/>
</div>
<div
class="Calculation none octo-table-cell"
style="width: 100px;"
tabindex="0"
>
<span
class="calculationLabel"
>
Calculate
</span>
<i
class="CompassIcon icon-chevron-up ChevronUpIcon"
/>
</div>
<div
class="Calculation none octo-table-cell"
class="Calculation none octo-table-cell "
style="width: 100px;"
tabindex="0"
>

View File

@ -12,6 +12,7 @@ import Calculation from '../../calculations/calculation'
import {columnWidth} from '../tableRow'
import {BoardView} from '../../../blocks/boardView'
import {Card} from '../../../blocks/card'
import {Options} from '../../calculations/options'
type Props = {
board: Board
@ -40,12 +41,20 @@ const CalculationRow = (props: Props): JSX.Element => {
const selectedCalculations = props.board.fields.columnCalculations || []
const [hovered, setHovered] = useState(false)
const toggleHover = () => setHovered(!hovered)
return (
<div className='CalculationRow octo-table-row'>
<div
className={'CalculationRow octo-table-row'}
onMouseEnter={toggleHover}
onMouseLeave={toggleHover}
>
{
templates.map((template) => {
const style = {width: columnWidth(props.resizingColumn, props.activeView.fields.columnWidths, props.offset, template.id)}
const value = selectedCalculations[template.id] || 'none'
const defaultValue = template.id === Constants.titleColumnId ? Options.count.value : Options.none.value
const value = selectedCalculations[template.id] || defaultValue
return (
<Calculation
@ -62,9 +71,11 @@ const CalculationRow = (props: Props): JSX.Element => {
const newBoard = createBoard(props.board)
newBoard.fields.columnCalculations = calculations
mutator.updateBlock(newBoard, props.board, 'update_calculation')
toggleHover()
}}
cards={props.cards}
property={template}
hovered={hovered}
/>
)
})

View File

@ -96,4 +96,3 @@ export const getCurrentBoard = createSelector(
return boards[boardId] || templates[boardId]
},
)

View File

@ -3,6 +3,12 @@
import marked from 'marked'
import {IntlShape} from 'react-intl'
import {Block} from './blocks/block'
import {createBoard} from './blocks/board'
import {createBoardView} from './blocks/boardView'
import {createCard} from './blocks/card'
import {createCommentBlock} from './blocks/commentBlock'
declare global {
interface Window {
msCrypto: Crypto
@ -416,6 +422,21 @@ class Utils {
static isFocalboardPlugin(): boolean {
return Boolean((window as any).isFocalboardPlugin)
}
static fixBlock(block: Block): Block {
switch (block.type) {
case 'board':
return createBoard(block)
case 'view':
return createBoardView(block)
case 'card':
return createCard(block)
case 'comment':
return createCommentBlock(block)
default:
return block
}
}
}
export {Utils}

View File

@ -82,6 +82,8 @@ const MenuWrapper = React.memo((props: Props) => {
return (
<div
role='button'
aria-label='menuwrapper'
className={className}
onClick={toggle}
ref={node}

View File

@ -1,5 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Utils} from './utils'
import {Block} from './blocks/block'
import {OctoUtils} from './octoUtils'
@ -19,6 +20,19 @@ type WSMessage = {
error?: string
}
export const ACTION_UPDATE_BLOCK = 'UPDATE_BLOCK'
export const ACTION_AUTH = 'AUTH'
export const ACTION_SUBSCRIBE_BLOCKS = 'SUBSCRIBE_BLOCKS'
export const ACTION_SUBSCRIBE_WORKSPACE = 'SUBSCRIBE_WORKSPACE'
export const ACTION_UNSUBSCRIBE_WORKSPACE = 'UNSUBSCRIBE_WORKSPACE'
export const ACTION_UNSUBSCRIBE_BLOCKS = 'UNSUBSCRIBE_BLOCKS'
// The Mattermost websocket client interface
export interface MMWebSocketClient {
sendMessage(action: string, data: any, responseCallback?: () => void): void
connectionId: string
}
type OnChangeHandler = (client: WSClient, blocks: Block[]) => void
type OnReconnectHandler = (client: WSClient) => void
type OnStateChangeHandler = (client: WSClient, state: 'init' | 'open' | 'close') => void
@ -26,12 +40,16 @@ type OnErrorHandler = (client: WSClient, e: Event) => void
class WSClient {
ws: WebSocket|null = null
client: MMWebSocketClient|null = null
clientPrefix = ''
serverUrl: string
state: 'init'|'open'|'close' = 'init'
onStateChange: OnStateChangeHandler[] = []
onReconnect: OnReconnectHandler[] = []
onChange: OnChangeHandler[] = []
onError: OnErrorHandler[] = []
private mmWSMaxRetries = 10
private mmWSRetryDelay = 300
private notificationDelay = 100
private reopenDelay = 3000
private updatedBlocks: Block[] = []
@ -42,6 +60,22 @@ class WSClient {
Utils.log(`WSClient serverUrl: ${this.serverUrl}`)
}
initPlugin(pluginId: string, client: MMWebSocketClient): void {
this.clientPrefix = `custom_${pluginId}_`
this.client = client
Utils.log(`WSClient initialised for plugin id "${pluginId}"`)
}
sendCommand(command: WSCommand): void {
if (this.client !== null) {
const {action, ...data} = command
this.client.sendMessage(this.clientPrefix + action, data)
return
}
this.ws?.send(JSON.stringify(command))
}
addOnChange(handler: OnChangeHandler): void {
this.onChange.push(handler)
}
@ -87,6 +121,33 @@ class WSClient {
}
open(): void {
if (this.client !== null) {
// WSClient needs to ensure that the Mattermost client has
// correctly stablished the connection before opening
let retries = 0
const setPluginOpen = () => {
if (this.client?.connectionId !== '') {
for (const handler of this.onStateChange) {
handler(this, 'open')
}
this.state = 'open'
Utils.log('WSClient in plugin mode, reusing Mattermost WS connection')
return
}
retries++
if (retries <= this.mmWSMaxRetries) {
Utils.log('WSClient Mattermost Websocket not ready, retrying')
setTimeout(setPluginOpen, this.mmWSRetryDelay)
} else {
Utils.logError('WSClient error on open: Mattermost Websocket client is not ready')
}
}
setPluginOpen()
return
}
const url = new URL(this.serverUrl)
const protocol = (url.protocol === 'https:') ? 'wss:' : 'ws:'
const wsServerUrl = `${protocol}//${url.host}${url.pathname.replace(/\/$/, '')}/ws`
@ -142,8 +203,8 @@ class WSClient {
}
switch (message.action) {
case 'UPDATE_BLOCK':
this.queueUpdateNotification(message.block!)
case ACTION_UPDATE_BLOCK:
this.updateBlockHandler(message)
break
default:
Utils.logError(`Unexpected action: ${message.action}`)
@ -154,8 +215,16 @@ class WSClient {
}
}
hasConn(): boolean {
return this.ws !== null || this.client !== null
}
updateBlockHandler(message: WSMessage): void {
this.queueUpdateNotification(Utils.fixBlock(message.block!))
}
authenticate(workspaceId: string, token: string): void {
if (!this.ws) {
if (!this.hasConn()) {
Utils.assertFailure('WSClient.addBlocks: ws is not open')
return
}
@ -164,71 +233,72 @@ class WSClient {
return
}
const command = {
action: 'AUTH',
action: ACTION_AUTH,
token,
workspaceId,
}
this.ws.send(JSON.stringify(command))
this.sendCommand(command)
}
subscribeToBlocks(workspaceId: string, blockIds: string[], readToken = ''): void {
if (!this.ws) {
if (!this.hasConn()) {
Utils.assertFailure('WSClient.subscribeToBlocks: ws is not open')
return
}
const command: WSCommand = {
action: 'SUBSCRIBE_BLOCKS',
action: ACTION_SUBSCRIBE_BLOCKS,
blockIds,
workspaceId,
readToken,
}
this.ws.send(JSON.stringify(command))
this.sendCommand(command)
}
unsubscribeToWorkspace(workspaceId: string): void {
if (!this.ws) {
if (!this.hasConn()) {
Utils.assertFailure('WSClient.subscribeToWorkspace: ws is not open')
return
}
const command: WSCommand = {
action: 'UNSUBSCRIBE_WORKSPACE',
action: ACTION_UNSUBSCRIBE_WORKSPACE,
workspaceId,
}
this.ws.send(JSON.stringify(command))
this.sendCommand(command)
}
subscribeToWorkspace(workspaceId: string): void {
if (!this.ws) {
if (!this.hasConn()) {
Utils.assertFailure('WSClient.subscribeToWorkspace: ws is not open')
return
}
const command: WSCommand = {
action: 'SUBSCRIBE_WORKSPACE',
action: ACTION_SUBSCRIBE_WORKSPACE,
workspaceId,
}
this.ws.send(JSON.stringify(command))
this.sendCommand(command)
}
unsubscribeFromBlocks(workspaceId: string, blockIds: string[], readToken = ''): void {
if (!this.ws) {
if (!this.hasConn()) {
Utils.assertFailure('WSClient.removeBlocks: ws is not open')
return
}
const command: WSCommand = {
action: 'UNSUBSCRIBE_BLOCKS',
action: ACTION_UNSUBSCRIBE_BLOCKS,
blockIds,
workspaceId,
readToken,
}
this.ws.send(JSON.stringify(command))
this.sendCommand(command)
}
private queueUpdateNotification(block: Block) {
@ -255,11 +325,11 @@ class WSClient {
}
close(): void {
if (!this.ws) {
if (!this.hasConn()) {
return
}
Utils.log(`WSClient close: ${this.ws.url}`)
Utils.log(`WSClient close: ${this.ws?.url}`)
// Use this sequence so the onclose method doesn't try to re-open
const ws = this.ws
@ -268,6 +338,12 @@ class WSClient {
this.onReconnect = []
this.onStateChange = []
this.onError = []
// if running in plugin mode, nothing else needs to be done
if (this.client) {
return
}
try {
ws?.close()
} catch {