You've already forked focalboard
							
							
				mirror of
				https://github.com/mattermost/focalboard.git
				synced 2025-10-31 00:17:42 +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:
		| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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." | ||||
|         }] | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
							
								
								
									
										99
									
								
								mattermost-plugin/server/configuration_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								mattermost-plugin/server/configuration_test.go
									
									
									
									
									
										Normal 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++ | ||||
| } | ||||
							
								
								
									
										13
									
								
								mattermost-plugin/server/manifest.go
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								mattermost-plugin/server/manifest.go
									
									
									
										generated
									
									
									
								
							| @@ -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 | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| } | ||||
| ` | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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 | ||||
| 		} | ||||
|  | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										22
									
								
								server/app/app_test.go
									
									
									
									
									
										Normal 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) | ||||
| 	}) | ||||
| } | ||||
| @@ -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, | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										26
									
								
								server/app/clientConfig_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								server/app/clientConfig_test.go
									
									
									
									
									
										Normal 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) | ||||
| 	}) | ||||
| } | ||||
| @@ -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"` | ||||
| } | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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") | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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() | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -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(() => { | ||||
|   | ||||
| @@ -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> | ||||
|  | ||||
|   | ||||
| @@ -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> | ||||
| `; | ||||
| @@ -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 */} | ||||
|   | ||||
| @@ -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() | ||||
|     }) | ||||
| }) | ||||
| @@ -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'})} | ||||
|   | ||||
| @@ -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} | ||||
|             /> | ||||
|         ) | ||||
|     } | ||||
|   | ||||
| @@ -4,4 +4,5 @@ | ||||
| export type ClientConfig = { | ||||
|     telemetry: boolean, | ||||
|     telemetryid: string, | ||||
|     enablePublicSharedBoards: boolean, | ||||
| } | ||||
|   | ||||
							
								
								
									
										38
									
								
								webapp/src/store/clientConfig.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								webapp/src/store/clientConfig.ts
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
|  | ||||
| @@ -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, | ||||
|     }, | ||||
| }) | ||||
|  | ||||
|   | ||||
| @@ -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') | ||||
|   | ||||
		Reference in New Issue
	
	Block a user