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:
commit
58f9c7e42f
@ -1,6 +1,6 @@
|
||||
module github.com/mattermost/focalboard/linux
|
||||
|
||||
go 1.15
|
||||
go 1.16
|
||||
|
||||
replace github.com/mattermost/focalboard/server => ../server
|
||||
|
||||
|
10
linux/go.sum
10
linux/go.sum
@ -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=
|
||||
|
@ -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
|
||||
|
@ -1,6 +1,6 @@
|
||||
module github.com/mattermost/focalboard/mattermost-plugin
|
||||
|
||||
go 1.12
|
||||
go 1.16
|
||||
|
||||
replace github.com/mattermost/focalboard/server => ../server
|
||||
|
||||
|
@ -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=
|
||||
|
@ -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",
|
||||
|
4
mattermost-plugin/server/manifest.go
generated
4
mattermost-plugin/server/manifest.go
generated
@ -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",
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,8 @@
|
||||
z-index: 1000;
|
||||
|
||||
.Menu {
|
||||
position: unset;
|
||||
min-width: unset;
|
||||
a,
|
||||
button {
|
||||
color: rgba(var(--center-channel-text-rgb), 1);
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
250
server/app/files_test.go
Normal 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())
|
||||
})
|
||||
}
|
@ -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=
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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
|
||||
|
113
server/services/telemetry/telemetry_test.go
Normal file
113
server/services/telemetry/telemetry_test.go
Normal 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
19
server/ws/adapter.go
Normal 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
314
server/ws/plugin_adapter.go
Normal 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)
|
||||
}
|
@ -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 {
|
@ -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"
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -18,6 +18,7 @@ interface BlockPatch {
|
||||
deletedFields?: string[]
|
||||
deleteAt?: number
|
||||
}
|
||||
|
||||
interface Block {
|
||||
id: string
|
||||
parentId: string
|
||||
|
@ -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>
|
||||
`;
|
@ -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 />`;
|
||||
|
111
webapp/src/components/addContentMenuItem.test.tsx
Normal file
111
webapp/src/components/addContentMenuItem.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
127
webapp/src/components/blockIconSelector.test.tsx
Normal file
127
webapp/src/components/blockIconSelector.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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: '',
|
||||
|
@ -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}
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -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"
|
||||
|
@ -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 "
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
@ -96,4 +96,3 @@ export const getCurrentBoard = createSelector(
|
||||
return boards[boardId] || templates[boardId]
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -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}
|
||||
|
@ -82,6 +82,8 @@ const MenuWrapper = React.memo((props: Props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
role='button'
|
||||
aria-label='menuwrapper'
|
||||
className={className}
|
||||
onClick={toggle}
|
||||
ref={node}
|
||||
|
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user