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

Config for share board (#1187)

* initial commit for setting shared board configuration

* add unit test

* working again

* update default config

* initial commit for setting shared board configuration

* add unit test

* working again

* update default config

* add unit tests, some clean up

* more cleanup

* more clean up

* remove header text for GH-1105

* remove unnecessary logs

* update text

* fix lint errors

* more lint fixes

* webapp lint fixes

* Update mattermost-plugin/plugin.json

Co-authored-by: Justine Geffen <justinegeffen@users.noreply.github.com>

* Update mattermost-plugin/plugin.json

Co-authored-by: Justine Geffen <justinegeffen@users.noreply.github.com>

* update for review, sync with main

Co-authored-by: Justine Geffen <justinegeffen@users.noreply.github.com>
This commit is contained in:
Scott Bishel 2021-09-16 13:31:02 -06:00 committed by GitHub
parent fd7be947c1
commit 838c6f90b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 712 additions and 73 deletions

View File

@ -2,7 +2,7 @@
"serverRoot": "http://localhost:8000",
"port": 8000,
"dbtype": "sqlite3",
"dbconfig": "./focalboard.db",
"dbconfig": "./focalboard.db",
"dbtableprefix": "",
"postgres_dbconfig": "dbname=focalboard sslmode=disable",
"test_dbconfig": "file::memory:?cache=shared",
@ -19,6 +19,7 @@
"enableLocalMode": true,
"localModeSocketLocation": "/var/tmp/focalboard_local.socket",
"authMode": "native",
"logging_cfg_file": "",
"audit_cfg_file": ""
"logging_cfg_file": "",
"audit_cfg_file": "",
"enablepublicsharedboards": false
}

View File

@ -19,8 +19,14 @@
"bundle_path": "webapp/dist/main.js"
},
"settings_schema": {
"header": "For additional setup steps, please [see here](https://focalboard.com/fwlink/plugin-setup.html)",
"header": "",
"footer": "",
"settings": []
"settings": [{
"key": "EnablePublicSharedBoards",
"type": "bool",
"display_name": "Enable Publicly-Shared Boards:",
"default": false,
"help_text": "This allows board editors to share boards that can be accessed by anyone with the link."
}]
}
}

View File

@ -3,6 +3,7 @@ package main
import (
"reflect"
"github.com/mattermost/focalboard/server/utils"
"github.com/pkg/errors"
)
@ -18,6 +19,7 @@ import (
// If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep
// copy appropriate for your types.
type configuration struct {
EnablePublicSharedBoards bool
}
// Clone shallow copies the configuration. Your implementation may require a deep copy if
@ -70,14 +72,20 @@ func (p *Plugin) setConfiguration(configuration *configuration) {
// OnConfigurationChange is invoked when configuration changes may have been made.
func (p *Plugin) OnConfigurationChange() error {
var configuration = new(configuration)
// Have we been setup by OnActivate?
if p.wsPluginAdapter == nil {
return nil
}
configuration := &configuration{}
// Load the public configuration fields from the Mattermost server configuration.
if err := p.API.LoadPluginConfiguration(configuration); err != nil {
if err := p.API.LoadPluginConfiguration(&configuration); err != nil {
return errors.Wrap(err, "failed to load plugin configuration")
}
p.setConfiguration(configuration)
p.server.UpdateClientConfig(utils.StructToMap(configuration))
p.wsPluginAdapter.BroadcastConfigChange(*p.server.App().GetClientConfig())
return nil
}

View File

@ -0,0 +1,99 @@
package main
import (
"errors"
"testing"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/ws"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var errLoadPluginConfiguration = errors.New("loadPluginConfiguration Error")
func TestConfiguration(t *testing.T) {
t.Run("null configuration", func(t *testing.T) {
plugin := &Plugin{}
assert.NotNil(t, plugin.getConfiguration())
})
t.Run("changing configuration", func(t *testing.T) {
plugin := &Plugin{}
configuration1 := &configuration{EnablePublicSharedBoards: false}
plugin.setConfiguration(configuration1)
assert.Equal(t, configuration1, plugin.getConfiguration())
configuration2 := &configuration{EnablePublicSharedBoards: true}
plugin.setConfiguration(configuration2)
assert.Equal(t, configuration2, plugin.getConfiguration())
assert.NotEqual(t, configuration1, plugin.getConfiguration())
assert.False(t, plugin.getConfiguration() == configuration1)
assert.True(t, plugin.getConfiguration() == configuration2)
})
t.Run("setting same configuration", func(t *testing.T) {
plugin := &Plugin{}
configuration1 := &configuration{}
plugin.setConfiguration(configuration1)
assert.Panics(t, func() {
plugin.setConfiguration(configuration1)
})
})
t.Run("clearing configuration", func(t *testing.T) {
plugin := &Plugin{}
configuration1 := &configuration{EnablePublicSharedBoards: true}
plugin.setConfiguration(configuration1)
assert.NotPanics(t, func() {
plugin.setConfiguration(nil)
})
assert.NotNil(t, plugin.getConfiguration())
assert.NotEqual(t, configuration1, plugin.getConfiguration())
})
}
func TestOnConfigurationChange(t *testing.T) {
t.Run("Test LoadPlugin Error", func(t *testing.T) {
api := &plugintest.API{}
api.On("LoadPluginConfiguration",
mock.Anything).Return(func(dest interface{}) error {
return errLoadPluginConfiguration
})
p := Plugin{}
p.SetAPI(api)
p.wsPluginAdapter = &ws.PluginAdapter{}
err := p.OnConfigurationChange()
assert.Error(t, err)
})
t.Run("Test Load Plugin Success", func(t *testing.T) {
api := &plugintest.API{}
api.On("LoadPluginConfiguration", mock.AnythingOfType("**main.configuration")).Return(nil)
p := Plugin{}
p.SetAPI(api)
p.wsPluginAdapter = &FakePluginAdapter{}
err := p.OnConfigurationChange()
require.NoError(t, err)
assert.Equal(t, 1, count)
})
}
var count = 0
type FakePluginAdapter struct {
ws.PluginAdapter
}
func (c *FakePluginAdapter) BroadcastConfigChange(clientConfig model.ClientConfig) {
count++
}

View File

@ -34,9 +34,18 @@ const manifestStr = `
"bundle_path": "webapp/dist/main.js"
},
"settings_schema": {
"header": "For additional setup steps, please [see here](https://focalboard.com/fwlink/plugin-setup.html)",
"header": "",
"footer": "",
"settings": []
"settings": [
{
"key": "EnablePublicSharedBoards",
"display_name": "Enable Publicly-Shared Boards:",
"type": "bool",
"help_text": "This allows board editors to share boards that can be accessed by anyone with the link.",
"placeholder": "",
"default": false
}
]
}
}
`

View File

@ -34,7 +34,7 @@ type Plugin struct {
configuration *configuration
server *server.Server
wsPluginAdapter *ws.PluginAdapter
wsPluginAdapter ws.PluginAdapterInterface
}
func (p *Plugin) OnActivate() error {
@ -100,27 +100,33 @@ func (p *Plugin) OnActivate() error {
enableTelemetry = *mmconfig.LogSettings.EnableDiagnostics
}
enablePublicSharedBoards := false
if mmconfig.PluginSettings.Plugins["focalboard"]["enablepublicsharedboards"] == true {
enablePublicSharedBoards = true
}
cfg := &config.Configuration{
ServerRoot: baseURL + "/plugins/focalboard",
Port: -1,
DBType: *mmconfig.SqlSettings.DriverName,
DBConfigString: *mmconfig.SqlSettings.DataSource,
DBTablePrefix: "focalboard_",
UseSSL: false,
SecureCookie: true,
WebPath: path.Join(*mmconfig.PluginSettings.Directory, "focalboard", "pack"),
FilesDriver: *mmconfig.FileSettings.DriverName,
FilesPath: *mmconfig.FileSettings.Directory,
FilesS3Config: filesS3Config,
Telemetry: enableTelemetry,
TelemetryID: serverID,
WebhookUpdate: []string{},
SessionExpireTime: 2592000,
SessionRefreshTime: 18000,
LocalOnly: false,
EnableLocalMode: false,
LocalModeSocketLocation: "",
AuthMode: "mattermost",
ServerRoot: baseURL + "/plugins/focalboard",
Port: -1,
DBType: *mmconfig.SqlSettings.DriverName,
DBConfigString: *mmconfig.SqlSettings.DataSource,
DBTablePrefix: "focalboard_",
UseSSL: false,
SecureCookie: true,
WebPath: path.Join(*mmconfig.PluginSettings.Directory, "focalboard", "pack"),
FilesDriver: *mmconfig.FileSettings.DriverName,
FilesPath: *mmconfig.FileSettings.Directory,
FilesS3Config: filesS3Config,
Telemetry: enableTelemetry,
TelemetryID: serverID,
WebhookUpdate: []string{},
SessionExpireTime: 2592000,
SessionRefreshTime: 18000,
LocalOnly: false,
EnableLocalMode: false,
LocalModeSocketLocation: "",
AuthMode: "mattermost",
EnablePublicSharedBoards: enablePublicSharedBoards,
}
var db store.Store
db, err = sqlstore.New(cfg.DBType, cfg.DBConfigString, cfg.DBTablePrefix, logger, sqlDB, true)

View File

@ -20,8 +20,6 @@ import GlobalHeader from '../../../webapp/src/components/globalHeader/globalHead
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 TelemetryClient from '../../../webapp/src/telemetry/telemetryClient'
import '../../../webapp/src/styles/focalboard-variables.scss'
@ -29,6 +27,8 @@ import '../../../webapp/src/styles/main.scss'
import '../../../webapp/src/styles/labels.scss'
import octoClient from '../../../webapp/src/octoClient'
import wsClient, {MMWebSocketClient, ACTION_UPDATE_BLOCK, ACTION_UPDATE_CLIENT_CONFIG} from './../../../webapp/src/wsclient'
import manifest from './manifest'
import ErrorBoundary from './error_boundary'
@ -186,6 +186,7 @@ export default class Plugin {
// register websocket handlers
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BLOCK}`, (e: any) => wsClient.updateBlockHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data))
}
uninitialize(): void {

View File

@ -174,7 +174,9 @@ func (a *API) getContainerAllowingReadTokenForBlock(r *http.Request, blockID str
}
// No session, but has valid read token (read-only mode)
if len(blockID) > 0 && a.hasValidReadTokenForBlock(r, container, blockID) {
if len(blockID) > 0 &&
a.hasValidReadTokenForBlock(r, container, blockID) &&
a.app.GetClientConfig().EnablePublicSharedBoards {
return &container, nil
}

View File

@ -36,6 +36,10 @@ type App struct {
logger *mlog.Logger
}
func (a *App) SetConfig(config *config.Configuration) {
a.config = config
}
func New(config *config.Configuration, wsAdapter ws.Adapter, services Services) *App {
return &App{
config: config,

22
server/app/app_test.go Normal file
View File

@ -0,0 +1,22 @@
package app
import (
"testing"
"github.com/mattermost/focalboard/server/services/config"
"github.com/stretchr/testify/require"
)
func TestSetConfig(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("Test Update Config", func(t *testing.T) {
require.False(t, th.App.config.EnablePublicSharedBoards)
newConfiguration := config.Configuration{}
newConfiguration.EnablePublicSharedBoards = true
th.App.SetConfig(&newConfiguration)
require.True(t, th.App.config.EnablePublicSharedBoards)
})
}

View File

@ -6,7 +6,8 @@ import (
func (a *App) GetClientConfig() *model.ClientConfig {
return &model.ClientConfig{
Telemetry: a.config.Telemetry,
TelemetryID: a.config.TelemetryID,
Telemetry: a.config.Telemetry,
TelemetryID: a.config.TelemetryID,
EnablePublicSharedBoards: a.config.EnablePublicSharedBoards,
}
}

View File

@ -0,0 +1,26 @@
package app
import (
"testing"
"github.com/mattermost/focalboard/server/services/config"
"github.com/stretchr/testify/require"
)
func TestGetClientConfig(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("Test Get Client Config", func(t *testing.T) {
newConfiguration := config.Configuration{}
newConfiguration.Telemetry = true
newConfiguration.TelemetryID = "abcde"
newConfiguration.EnablePublicSharedBoards = true
th.App.SetConfig(&newConfiguration)
clientConfig := th.App.GetClientConfig()
require.True(t, clientConfig.EnablePublicSharedBoards)
require.True(t, clientConfig.Telemetry)
require.Equal(t, "abcde", clientConfig.TelemetryID)
})
}

View File

@ -1,6 +1,7 @@
package model
type ClientConfig struct {
Telemetry bool `json:"telemetry"`
TelemetryID string `json:"telemetryid"`
Telemetry bool `json:"telemetry"`
TelemetryID string `json:"telemetryid"`
EnablePublicSharedBoards bool `json:"enablePublicSharedBoards"`
}

View File

@ -68,6 +68,7 @@ type Server struct {
localRouter *mux.Router
localModeServer *http.Server
api *api.API
app *app.App
}
func New(params Params) (*Server, error) {
@ -198,6 +199,7 @@ func New(params Params) (*Server, error) {
logger: params.Logger,
localRouter: localRouter,
api: focalboardAPI,
app: app,
}
server.initHandlers()
@ -347,6 +349,23 @@ func (s *Server) Logger() *mlog.Logger {
return s.logger
}
func (s *Server) App() *app.App {
return s.app
}
func (s *Server) UpdateClientConfig(pluginConfig map[string]interface{}) {
for index, value := range pluginConfig {
if index == "EnablePublicSharedBoards" {
b, ok := value.(bool)
if !ok {
s.logger.Warn("Invalid value for config value", mlog.String(index, value.(string)))
}
s.config.EnablePublicSharedBoards = b
}
}
s.app.SetConfig(s.config)
}
// Local server
func (s *Server) startLocalModeServer() error {

View File

@ -26,27 +26,28 @@ type AmazonS3Config struct {
// Configuration is the app configuration stored in a json file.
type Configuration struct {
ServerRoot string `json:"serverRoot" mapstructure:"serverRoot"`
Port int `json:"port" mapstructure:"port"`
DBType string `json:"dbtype" mapstructure:"dbtype"`
DBConfigString string `json:"dbconfig" mapstructure:"dbconfig"`
DBTablePrefix string `json:"dbtableprefix" mapstructure:"dbtableprefix"`
UseSSL bool `json:"useSSL" mapstructure:"useSSL"`
SecureCookie bool `json:"secureCookie" mapstructure:"secureCookie"`
WebPath string `json:"webpath" mapstructure:"webpath"`
FilesDriver string `json:"filesdriver" mapstructure:"filesdriver"`
FilesS3Config AmazonS3Config `json:"filess3config" mapstructure:"filess3config"`
FilesPath string `json:"filespath" mapstructure:"filespath"`
Telemetry bool `json:"telemetry" mapstructure:"telemetry"`
TelemetryID string `json:"telemetryid" mapstructure:"telemetryid"`
PrometheusAddress string `json:"prometheus_address" mapstructure:"prometheus_address"`
WebhookUpdate []string `json:"webhook_update" mapstructure:"webhook_update"`
Secret string `json:"secret" mapstructure:"secret"`
SessionExpireTime int64 `json:"session_expire_time" mapstructure:"session_expire_time"`
SessionRefreshTime int64 `json:"session_refresh_time" mapstructure:"session_refresh_time"`
LocalOnly bool `json:"localonly" mapstructure:"localonly"`
EnableLocalMode bool `json:"enableLocalMode" mapstructure:"enableLocalMode"`
LocalModeSocketLocation string `json:"localModeSocketLocation" mapstructure:"localModeSocketLocation"`
ServerRoot string `json:"serverRoot" mapstructure:"serverRoot"`
Port int `json:"port" mapstructure:"port"`
DBType string `json:"dbtype" mapstructure:"dbtype"`
DBConfigString string `json:"dbconfig" mapstructure:"dbconfig"`
DBTablePrefix string `json:"dbtableprefix" mapstructure:"dbtableprefix"`
UseSSL bool `json:"useSSL" mapstructure:"useSSL"`
SecureCookie bool `json:"secureCookie" mapstructure:"secureCookie"`
WebPath string `json:"webpath" mapstructure:"webpath"`
FilesDriver string `json:"filesdriver" mapstructure:"filesdriver"`
FilesS3Config AmazonS3Config `json:"filess3config" mapstructure:"filess3config"`
FilesPath string `json:"filespath" mapstructure:"filespath"`
Telemetry bool `json:"telemetry" mapstructure:"telemetry"`
TelemetryID string `json:"telemetryid" mapstructure:"telemetryid"`
PrometheusAddress string `json:"prometheus_address" mapstructure:"prometheus_address"`
WebhookUpdate []string `json:"webhook_update" mapstructure:"webhook_update"`
Secret string `json:"secret" mapstructure:"secret"`
SessionExpireTime int64 `json:"session_expire_time" mapstructure:"session_expire_time"`
SessionRefreshTime int64 `json:"session_refresh_time" mapstructure:"session_refresh_time"`
LocalOnly bool `json:"localonly" mapstructure:"localonly"`
EnableLocalMode bool `json:"enableLocalMode" mapstructure:"enableLocalMode"`
LocalModeSocketLocation string `json:"localModeSocketLocation" mapstructure:"localModeSocketLocation"`
EnablePublicSharedBoards bool `json:"enablePublicSharedBoards" mapstructure:"enablePublicSharedBoards"`
AuthMode string `json:"authMode" mapstructure:"authMode"`
@ -81,6 +82,7 @@ func ReadConfigFile() (*Configuration, error) {
viper.SetDefault("LocalOnly", false)
viper.SetDefault("EnableLocalMode", false)
viper.SetDefault("LocalModeSocketLocation", "/var/tmp/focalboard_local.socket")
viper.SetDefault("EnablePublicSharedBoards", false)
viper.SetDefault("AuthMode", "native")

View File

@ -2,6 +2,7 @@ package utils
import (
"crypto/rand"
"encoding/json"
"fmt"
"log"
"time"
@ -23,3 +24,9 @@ func CreateGUID() string {
func GetMillis() int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}
func StructToMap(v interface{}) (m map[string]interface{}) {
b, _ := json.Marshal(v)
_ = json.Unmarshal(b, &m)
return
}

View File

@ -11,9 +11,11 @@ const (
websocketActionSubscribeBlocks = "SUBSCRIBE_BLOCKS"
websocketActionUnsubscribeBlocks = "UNSUBSCRIBE_BLOCKS"
websocketActionUpdateBlock = "UPDATE_BLOCK"
websocketActionUpdateConfig = "UPDATE_CLIENT_CONFIG"
)
type Adapter interface {
BroadcastBlockChange(workspaceID string, block model.Block)
BroadcastBlockDelete(workspaceID, blockID, parentID string)
BroadcastConfigChange(clientConfig model.ClientConfig)
}

View File

@ -1,7 +1,6 @@
package ws
import (
"encoding/json"
"fmt"
"strings"
"sync"
@ -9,6 +8,7 @@ import (
"github.com/mattermost/focalboard/server/auth"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/utils"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin"
)
@ -17,12 +17,6 @@ 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
@ -40,7 +34,6 @@ func (pac *PluginAdapterClient) isSubscribedToWorkspace(workspaceID string) bool
return false
}
//nolint:unused
func (pac *PluginAdapterClient) isSubscribedToBlock(blockID string) bool {
for _, id := range pac.blocks {
if id == blockID {
@ -51,6 +44,24 @@ func (pac *PluginAdapterClient) isSubscribedToBlock(blockID string) bool {
return false
}
type PluginAdapterInterface interface {
addListener(pac *PluginAdapterClient)
removeListener(pac *PluginAdapterClient)
removeListenerFromWorkspace(pac *PluginAdapterClient, workspaceID string)
removeListenerFromBlock(pac *PluginAdapterClient, blockID string)
subscribeListenerToWorkspace(pac *PluginAdapterClient, workspaceID string)
unsubscribeListenerFromWorkspace(pac *PluginAdapterClient, workspaceID string)
unsubscribeListenerFromBlocks(pac *PluginAdapterClient, blockIDs []string)
OnWebSocketConnect(webConnID, userID string)
OnWebSocketDisconnect(webConnID, userID string)
WebSocketMessageHasBeenPosted(webConnID, userID string, req *mmModel.WebSocketRequest)
getUserIDsForWorkspace(workspaceID string) []string
BroadcastConfigChange(clientConfig model.ClientConfig)
BroadcastBlockChange(workspaceID string, block model.Block)
BroadcastBlockDelete(workspaceID, blockID, parentID string)
HandleClusterEvent(ev mmModel.PluginClusterEvent)
}
type PluginAdapter struct {
api plugin.API
auth *auth.Auth
@ -154,7 +165,6 @@ func (pa *PluginAdapter) unsubscribeListenerFromWorkspace(pac *PluginAdapterClie
pa.removeListenerFromWorkspace(pac, workspaceID)
}
//nolint:unused
func (pa *PluginAdapter) unsubscribeListenerFromBlocks(pac *PluginAdapterClient, blockIDs []string) {
pa.mu.Lock()
defer pa.mu.Unlock()
@ -285,6 +295,24 @@ func (pa *PluginAdapter) getUserIDsForWorkspace(workspaceID string) []string {
return userIDs
}
func (pa *PluginAdapter) sendMessageToAllSkipCluster(payload map[string]interface{}) {
// Empty &mmModel.WebsocketBroadcast will send to all users
pa.api.PublishWebSocketEvent(websocketActionUpdateConfig, payload, &mmModel.WebsocketBroadcast{})
}
func (pa *PluginAdapter) sendMessageToAll(payload map[string]interface{}) {
go func() {
clusterMessage := &ClusterMessage{Payload: payload}
pa.sendMessageToCluster("websocket_message", clusterMessage)
}()
pa.sendMessageToAllSkipCluster(payload)
}
func (pa *PluginAdapter) BroadcastConfigChange(pluginConfig model.ClientConfig) {
pa.sendMessageToAll(utils.StructToMap(pluginConfig))
}
// sendWorkspaceMessageSkipCluster sends a message to all the users
// with a websocket client connected to.
func (pa *PluginAdapter) sendWorkspaceMessageSkipCluster(workspaceID string, payload map[string]interface{}) {
@ -320,7 +348,7 @@ func (pa *PluginAdapter) BroadcastBlockChange(workspaceID string, block model.Bl
Block: block,
}
pa.sendWorkspaceMessage(workspaceID, structToMap(message))
pa.sendWorkspaceMessage(workspaceID, utils.StructToMap(message))
}
func (pa *PluginAdapter) BroadcastBlockDelete(workspaceID, blockID, parentID string) {

View File

@ -64,6 +64,12 @@ type Server struct {
logger *mlog.Logger
}
// UpdateClientConfig is sent on block updates.
type UpdateClientConfig struct {
Action string `json:"action"`
ClientConfig model.ClientConfig `json:"clientconfig"`
}
type websocketSession struct {
client *wsClient
userID string
@ -505,3 +511,27 @@ func (ws *Server) BroadcastBlockChange(workspaceID string, block model.Block) {
}
}
}
// BroadcastConfigChange broadcasts update messages to clients.
func (ws *Server) BroadcastConfigChange(clientConfig model.ClientConfig) {
message := UpdateClientConfig{
Action: websocketActionUpdateConfig,
ClientConfig: clientConfig,
}
listeners := ws.listeners
ws.logger.Debug("broadcasting config change to listener(s)",
mlog.Int("listener_count", len(listeners)),
)
for listener := range listeners {
ws.logger.Debug("Broadcast Config change",
mlog.Stringer("remoteAddr", listener.RemoteAddr()),
)
err := listener.WriteJSON(message)
if err != nil {
ws.logger.Error("broadcast error", mlog.Err(err))
listener.Close()
}
}
}

View File

@ -30,6 +30,7 @@ import {fetchMe, getLoggedIn, getMe} from './store/users'
import {getLanguage, fetchLanguage} from './store/language'
import {setGlobalError, getGlobalError} from './store/globalError'
import {useAppSelector, useAppDispatch} from './store/hooks'
import {fetchClientConfig} from './store/clientConfig'
import {IUser} from './user'
@ -79,6 +80,7 @@ const App = React.memo((): JSX.Element => {
useEffect(() => {
dispatch(fetchLanguage())
dispatch(fetchMe())
dispatch(fetchClientConfig())
}, [])
useEffect(() => {

View File

@ -43,6 +43,7 @@ type Props = {
addTemplate: (template: Card) => void
shownCardId?: string
showCard: (cardId?: string) => void
showShared: boolean
}
type State = {
@ -151,6 +152,7 @@ class CenterPanel extends React.Component<Props, State> {
addCardTemplate={this.addCardTemplate}
editCardTemplate={this.editCardTemplate}
readonly={this.props.readonly}
showShared={this.props.showShared}
/>
</div>

View File

@ -0,0 +1,204 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/viewHeader/viewHeaderActionsMenu return menu with Share Boards 1`] = `
<div>
<div
class="ModalWrapper"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
role="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
<div
class="Menu noselect bottom"
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div
aria-label="Export to CSV"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Export to CSV
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Export board archive"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Export board archive
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Share board"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Share board
</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/viewHeader/viewHeaderActionsMenu return menu without Share Boards 1`] = `
<div>
<div
class="ModalWrapper"
>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<div
class="Button IconButton"
role="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</div>
<div
class="Menu noselect bottom"
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div
aria-label="Export to CSV"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Export to CSV
</div>
<div
class="noicon"
/>
</div>
<div
aria-label="Export board archive"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="noicon"
/>
<div
class="menu-name"
>
Export board archive
</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>
`;

View File

@ -37,12 +37,13 @@ type Props = {
addCardTemplate: () => void
editCardTemplate: (cardTemplateId: string) => void
readonly: boolean
showShared: boolean
}
const ViewHeader = React.memo((props: Props) => {
const [showFilter, setShowFilter] = useState(false)
const {board, activeView, views, groupByProperty, cards} = props
const {board, activeView, views, groupByProperty, cards, showShared} = props
const withGroupBy = activeView.fields.viewType === 'board' || activeView.fields.viewType === 'table'
@ -142,6 +143,7 @@ const ViewHeader = React.memo((props: Props) => {
board={board}
activeView={activeView}
cards={cards}
showShared={showShared}
/>
{/* New card button */}

View File

@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactElement} from 'react'
import {render, screen} from '@testing-library/react'
import {Provider as ReduxProvider} from 'react-redux'
import configureStore from 'redux-mock-store'
import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'
import {IntlProvider} from 'react-intl'
import {TestBlockFactory} from '../../test/testBlockFactory'
import ViewHeaderActionsMenu from './viewHeaderActionsMenu'
const wrapIntl = (children: ReactElement) => (
<IntlProvider locale='en'>{children}</IntlProvider>
)
const board = TestBlockFactory.createBoard()
const activeView = TestBlockFactory.createBoardView(board)
const card = TestBlockFactory.createCard(board)
describe('components/viewHeader/viewHeaderActionsMenu', () => {
const state = {
users: {
me: {
id: 'user-id-1',
username: 'username_1'},
},
}
const mockStore = configureStore([])
const store = mockStore(state)
test('return menu with Share Boards', () => {
const {container} = render(
wrapIntl(
<ReduxProvider store={store}>
<ViewHeaderActionsMenu
board={board}
activeView={activeView}
cards={[card]}
showShared={true}
/>
</ReduxProvider>,
),
)
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
userEvent.click(buttonElement)
expect(container).toMatchSnapshot()
})
test('return menu without Share Boards', () => {
const {container} = render(
wrapIntl(
<ReduxProvider store={store}>
<ViewHeaderActionsMenu
board={board}
activeView={activeView}
cards={[card]}
showShared={false}
/>
</ReduxProvider>,
),
)
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
userEvent.click(buttonElement)
expect(container).toMatchSnapshot()
})
})

View File

@ -24,6 +24,7 @@ type Props = {
board: Board
activeView: BoardView
cards: Card[]
showShared: boolean
}
// import {mutator} from '../../mutator'
@ -103,6 +104,8 @@ const ViewHeaderActionsMenu = React.memo((props: Props) => {
const user = useAppSelector<IUser|null>(getMe)
const intl = useIntl()
const showShareBoard = user && user.id !== 'single-user' && props.showShared
return (
<ModalWrapper>
<MenuWrapper>
@ -118,7 +121,7 @@ const ViewHeaderActionsMenu = React.memo((props: Props) => {
name={intl.formatMessage({id: 'ViewHeader.export-board-archive', defaultMessage: 'Export board archive'})}
onClick={() => Archiver.exportBoardArchive(board)}
/>
{user && user.id !== 'single-user' &&
{showShareBoard &&
<Menu.Text
id='shareBoard'
name={intl.formatMessage({id: 'ViewHeader.share-board', defaultMessage: 'Share board'})}

View File

@ -1,16 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react'
import React, {useCallback, useEffect} from 'react'
import {generatePath, useRouteMatch, useHistory} from 'react-router-dom'
import {FormattedMessage} from 'react-intl'
import {getCurrentBoard} from '../store/boards'
import {getCurrentViewCardsSortedFilteredAndGrouped} from '../store/cards'
import {getView, getCurrentBoardViews, getCurrentViewGroupBy, getCurrentView} from '../store/views'
import {useAppSelector} from '../store/hooks'
import {useAppSelector, useAppDispatch} from '../store/hooks'
import {getClientConfig, setClientConfig} from '../store/clientConfig'
import wsClient, {WSClient} from '../wsclient'
import {ClientConfig} from '../config/clientConfig'
import CenterPanel from './centerPanel'
import EmptyCenterPanel from './emptyCenterPanel'
import Sidebar from './sidebar/sidebar'
import './workspace.scss'
@ -25,7 +31,9 @@ function CenterContent(props: Props) {
const activeView = useAppSelector(getView(match.params.viewId))
const views = useAppSelector(getCurrentBoardViews)
const groupByProperty = useAppSelector(getCurrentViewGroupBy)
const clientConfig = useAppSelector(getClientConfig)
const history = useHistory()
const dispatch = useAppDispatch()
const showCard = useCallback((cardId?: string) => {
const params = {...match.params, cardId}
@ -33,6 +41,16 @@ function CenterContent(props: Props) {
history.push(newPath)
}, [match, history])
useEffect(() => {
const onConfigChangeHandler = (_: WSClient, config: ClientConfig) => {
dispatch(setClientConfig(config))
}
wsClient.addOnConfigChange(onConfigChangeHandler)
return () => {
wsClient.removeOnConfigChange(onConfigChangeHandler)
}
}, [])
if (board && activeView) {
let property = groupByProperty
if ((!property || property.type !== 'select') && activeView.fields.viewType === 'board') {
@ -48,6 +66,7 @@ function CenterContent(props: Props) {
activeView={activeView}
groupByProperty={property}
views={views}
showShared={clientConfig?.enablePublicSharedBoards || false}
/>
)
}

View File

@ -4,4 +4,5 @@
export type ClientConfig = {
telemetry: boolean,
telemetryid: string,
enablePublicSharedBoards: boolean,
}

View File

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSlice, createAsyncThunk, PayloadAction} from '@reduxjs/toolkit'
import {ClientConfig} from '../config/clientConfig'
import {default as client} from '../octoClient'
import {RootState} from './index'
export const fetchClientConfig = createAsyncThunk(
'clientConfig/fetchClientConfig',
async () => client.getClientConfig(),
)
const clientConfigSlice = createSlice({
name: 'config',
initialState: {value: {telemetry: false, telemetryid: '', enablePublicSharedBoards: false}} as {value: ClientConfig},
reducers: {
setClientConfig: (state, action: PayloadAction<ClientConfig>) => {
state.value = action.payload
},
},
extraReducers: (builder) => {
builder.addCase(fetchClientConfig.fulfilled, (state, action) => {
state.value = action.payload || {telemetry: false, telemetryid: '', enablePublicSharedBoards: false}
})
},
})
export const {setClientConfig} = clientConfigSlice.actions
export const {reducer} = clientConfigSlice
export function getClientConfig(state: RootState): ClientConfig {
return state.clientConfig.value
}

View File

@ -14,6 +14,7 @@ import {reducer as contentsReducer} from './contents'
import {reducer as commentsReducer} from './comments'
import {reducer as searchTextReducer} from './searchText'
import {reducer as globalErrorReducer} from './globalError'
import {reducer as clientConfigReducer} from './clientConfig'
const store = configureStore({
reducer: {
@ -28,6 +29,7 @@ const store = configureStore({
comments: commentsReducer,
searchText: searchTextReducer,
globalError: globalErrorReducer,
clientConfig: clientConfigReducer,
},
})

View File

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ClientConfig} from './config/clientConfig'
import {Utils} from './utils'
import {Block} from './blocks/block'
import {OctoUtils} from './octoUtils'
@ -26,6 +28,7 @@ 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'
export const ACTION_UPDATE_CLIENT_CONFIG = 'UPDATE_CLIENT_CONFIG'
// The Mattermost websocket client interface
export interface MMWebSocketClient {
@ -37,6 +40,7 @@ type OnChangeHandler = (client: WSClient, blocks: Block[]) => void
type OnReconnectHandler = (client: WSClient) => void
type OnStateChangeHandler = (client: WSClient, state: 'init' | 'open' | 'close') => void
type OnErrorHandler = (client: WSClient, e: Event) => void
type OnConfigChangeHandler = (client: WSClient, clientConfig: ClientConfig) => void
class WSClient {
ws: WebSocket|null = null
@ -48,6 +52,7 @@ class WSClient {
onReconnect: OnReconnectHandler[] = []
onChange: OnChangeHandler[] = []
onError: OnErrorHandler[] = []
onConfigChange: OnConfigChangeHandler[] = []
private mmWSMaxRetries = 100
private mmWSRetryDelay = 300
private notificationDelay = 100
@ -139,6 +144,17 @@ class WSClient {
}
}
addOnConfigChange(handler: OnConfigChangeHandler): void {
this.onConfigChange.push(handler)
}
removeOnConfigChange(handler: OnConfigChangeHandler): void {
const index = this.onConfigChange.indexOf(handler)
if (index !== -1) {
this.onConfigChange.splice(index, 1)
}
}
open(): void {
if (this.client !== null) {
// WSClient needs to ensure that the Mattermost client has
@ -208,7 +224,6 @@ class WSClient {
}
ws.onmessage = (e) => {
// Utils.log(`WSClient websocket onmessage. data: ${e.data}`)
if (ws !== this.ws) {
Utils.log('Ignoring closed ws')
return
@ -242,6 +257,12 @@ class WSClient {
this.queueUpdateNotification(Utils.fixBlock(message.block!))
}
updateClientConfigHandler(config: ClientConfig): void {
for (const handler of this.onConfigChange) {
handler(this, config)
}
}
authenticate(workspaceId: string, token: string): void {
if (!this.hasConn()) {
Utils.assertFailure('WSClient.addBlocks: ws is not open')