Repository: vmware-tanzu/octant
Pull request #1233 (2020-08-10 15:24:31)

Unblock Storybook
Eliminate unhandled Promise rejections and configure the Monaco baseUrl
centrally for storybook.

Ref #1214

**Release note**:
```
none
```



Patch:
diff --git a/go.mod b/go.mod
index 53b4c122..4c36822f 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
 	github.com/asticode/go-astikit v0.8.0
 	github.com/asticode/go-astilectron v0.14.2
 	github.com/asticode/go-astilectron-bundler v0.5.2
+	github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect
 	github.com/davecgh/go-spew v1.1.1
 	github.com/dlclark/regexp2 v1.2.0 // indirect
 	github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c // indirect
@@ -27,6 +28,7 @@ require (
 	github.com/gorilla/websocket v1.4.2
 	github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
 	github.com/hashicorp/go-hclog v0.14.1
+	github.com/hashicorp/go-multierror v1.0.0
 	github.com/hashicorp/go-plugin v0.0.0-20190220160451-3f118e8ee104
 	github.com/hashicorp/golang-lru v0.5.4
 	github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect
diff --git a/go.sum b/go.sum
index 041bb744..25055c41 100644
--- a/go.sum
+++ b/go.sum
@@ -105,6 +105,7 @@ github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg=
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@@ -288,6 +289,7 @@ github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t
 github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
 github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
 github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
+github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
 github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
@@ -295,6 +297,7 @@ github.com/hashicorp/go-hclog v0.14.1 h1:nQcJDQwIAGnmoUWp8ubocEX40cCml/17YkF6csQ
 github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
 github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
 github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
+github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
 github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
 github.com/hashicorp/go-plugin v0.0.0-20190220160451-3f118e8ee104 h1:9iQ/zrTOJqzP+kH37s6xNb6T1RysiT7fnDD3DJbspVw=
 github.com/hashicorp/go-plugin v0.0.0-20190220160451-3f118e8ee104/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY=
diff --git a/internal/config/dash.go b/internal/config/dash.go
index 758ecd2d..1ba41eea 100644
--- a/internal/config/dash.go
+++ b/internal/config/dash.go
@@ -8,6 +8,7 @@ package config
 import (
 	"context"
 
+	"github.com/vmware-tanzu/octant/internal/octant"
 	"github.com/vmware-tanzu/octant/pkg/store"
 
 	"github.com/pkg/errors"
@@ -77,14 +78,13 @@ func (c *CRDWatchConfig) CanPerform(u *unstructured.Unstructured) bool {
 // Config is configuration for dash. It has knowledge of the all the major sections of
 // dash.
 type Dash interface {
-	ObjectPath(namespace, apiVersion, kind, name string) (string, error)
+	octant.LinkGenerator
+	octant.Storage
 
 	ClusterClient() cluster.ClientInterface
 
 	CRDWatcher() CRDWatcher
 
-	ObjectStore() store.Store
-
 	ErrorStore() internalErr.ErrorStore
 
 	Logger() log.Logger
@@ -235,7 +235,7 @@ func (l *Live) UseContext(ctx context.Context, contextName string) error {
 		}
 	}
 
-	l.pluginManager.UpdateObjectStore(l.objectStore)
+	l.pluginManager.SetOctantClient(l)
 
 	return nil
 }
diff --git a/internal/objectstore/dynamic_cache.go b/internal/objectstore/dynamic_cache.go
index 061fb410..01481038 100644
--- a/internal/objectstore/dynamic_cache.go
+++ b/internal/objectstore/dynamic_cache.go
@@ -657,7 +657,7 @@ func CreateOrUpdateFromHandler(
 	}
 
 	logger := log.From(ctx)
-	results := []string{}
+	var results []string
 	err := withDoc(func(doc map[string]interface{}) error {
 		logger.Debugf("apply resource %#v", doc)
 
diff --git a/internal/octant/fake/mock_dashboard_client.go b/internal/octant/fake/mock_dashboard_client.go
new file mode 100644
index 00000000..b6d03c1a
--- /dev/null
+++ b/internal/octant/fake/mock_dashboard_client.go
@@ -0,0 +1,64 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/vmware-tanzu/octant/internal/octant (interfaces: DashboardClientFunction)
+
+// Package fake is a generated GoMock package.
+package fake
+
+import (
+	context "context"
+	reflect "reflect"
+
+	goja "github.com/dop251/goja"
+	gomock "github.com/golang/mock/gomock"
+)
+
+// MockDashboardClientFunction is a mock of DashboardClientFunction interface
+type MockDashboardClientFunction struct {
+	ctrl     *gomock.Controller
+	recorder *MockDashboardClientFunctionMockRecorder
+}
+
+// MockDashboardClientFunctionMockRecorder is the mock recorder for MockDashboardClientFunction
+type MockDashboardClientFunctionMockRecorder struct {
+	mock *MockDashboardClientFunction
+}
+
+// NewMockDashboardClientFunction creates a new mock instance
+func NewMockDashboardClientFunction(ctrl *gomock.Controller) *MockDashboardClientFunction {
+	mock := &MockDashboardClientFunction{ctrl: ctrl}
+	mock.recorder = &MockDashboardClientFunctionMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockDashboardClientFunction) EXPECT() *MockDashboardClientFunctionMockRecorder {
+	return m.recorder
+}
+
+// Call mocks base method
+func (m *MockDashboardClientFunction) Call(arg0 context.Context, arg1 *goja.Runtime) func(goja.FunctionCall) goja.Value {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Call", arg0, arg1)
+	ret0, _ := ret[0].(func(goja.FunctionCall) goja.Value)
+	return ret0
+}
+
+// Call indicates an expected call of Call
+func (mr *MockDashboardClientFunctionMockRecorder) Call(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockDashboardClientFunction)(nil).Call), arg0, arg1)
+}
+
+// Name mocks base method
+func (m *MockDashboardClientFunction) Name() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Name")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// Name indicates an expected call of Name
+func (mr *MockDashboardClientFunctionMockRecorder) Name() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockDashboardClientFunction)(nil).Name))
+}
diff --git a/internal/octant/fake/mock_link_generator.go b/internal/octant/fake/mock_link_generator.go
new file mode 100644
index 00000000..63e72e69
--- /dev/null
+++ b/internal/octant/fake/mock_link_generator.go
@@ -0,0 +1,49 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/vmware-tanzu/octant/internal/octant (interfaces: LinkGenerator)
+
+// Package fake is a generated GoMock package.
+package fake
+
+import (
+	reflect "reflect"
+
+	gomock "github.com/golang/mock/gomock"
+)
+
+// MockLinkGenerator is a mock of LinkGenerator interface
+type MockLinkGenerator struct {
+	ctrl     *gomock.Controller
+	recorder *MockLinkGeneratorMockRecorder
+}
+
+// MockLinkGeneratorMockRecorder is the mock recorder for MockLinkGenerator
+type MockLinkGeneratorMockRecorder struct {
+	mock *MockLinkGenerator
+}
+
+// NewMockLinkGenerator creates a new mock instance
+func NewMockLinkGenerator(ctrl *gomock.Controller) *MockLinkGenerator {
+	mock := &MockLinkGenerator{ctrl: ctrl}
+	mock.recorder = &MockLinkGeneratorMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockLinkGenerator) EXPECT() *MockLinkGeneratorMockRecorder {
+	return m.recorder
+}
+
+// ObjectPath mocks base method
+func (m *MockLinkGenerator) ObjectPath(arg0, arg1, arg2, arg3 string) (string, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ObjectPath", arg0, arg1, arg2, arg3)
+	ret0, _ := ret[0].(string)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// ObjectPath indicates an expected call of ObjectPath
+func (mr *MockLinkGeneratorMockRecorder) ObjectPath(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ObjectPath", reflect.TypeOf((*MockLinkGenerator)(nil).ObjectPath), arg0, arg1, arg2, arg3)
+}
diff --git a/internal/octant/fake/mock_storage.go b/internal/octant/fake/mock_storage.go
new file mode 100644
index 00000000..a034bf44
--- /dev/null
+++ b/internal/octant/fake/mock_storage.go
@@ -0,0 +1,50 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/vmware-tanzu/octant/internal/octant (interfaces: Storage)
+
+// Package fake is a generated GoMock package.
+package fake
+
+import (
+	reflect "reflect"
+
+	gomock "github.com/golang/mock/gomock"
+
+	store "github.com/vmware-tanzu/octant/pkg/store"
+)
+
+// MockStorage is a mock of Storage interface
+type MockStorage struct {
+	ctrl     *gomock.Controller
+	recorder *MockStorageMockRecorder
+}
+
+// MockStorageMockRecorder is the mock recorder for MockStorage
+type MockStorageMockRecorder struct {
+	mock *MockStorage
+}
+
+// NewMockStorage creates a new mock instance
+func NewMockStorage(ctrl *gomock.Controller) *MockStorage {
+	mock := &MockStorage{ctrl: ctrl}
+	mock.recorder = &MockStorageMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockStorage) EXPECT() *MockStorageMockRecorder {
+	return m.recorder
+}
+
+// ObjectStore mocks base method
+func (m *MockStorage) ObjectStore() store.Store {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ObjectStore")
+	ret0, _ := ret[0].(store.Store)
+	return ret0
+}
+
+// ObjectStore indicates an expected call of ObjectStore
+func (mr *MockStorageMockRecorder) ObjectStore() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ObjectStore", reflect.TypeOf((*MockStorage)(nil).ObjectStore))
+}
diff --git a/internal/octant/javascript.go b/internal/octant/javascript.go
new file mode 100644
index 00000000..88ee939e
--- /dev/null
+++ b/internal/octant/javascript.go
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package octant
+
+import (
+	"context"
+
+	"github.com/dop251/goja"
+)
+
+//go:generate mockgen -destination=./fake/mock_dashboard_client.go -package=fake . DashboardClientFunction
+
+// DashboardClientFactory is an interface for a factory that creates dashboard clients.
+type DashboardClientFactory interface {
+	// Create creates a dashboard clients in a goja value.
+	Create(ctx context.Context, vm *goja.Runtime) goja.Value
+}
+
+// DashboardClientFunction is a function in the the dashboard client.
+type DashboardClientFunction interface {
+	// Name returns the name of the function.
+	Name() string
+	// Call generates a function that executes the function.
+	Call(ctx context.Context, vm *goja.Runtime) func(c goja.FunctionCall) goja.Value
+}
diff --git a/internal/octant/link_generator.go b/internal/octant/link_generator.go
new file mode 100644
index 00000000..e9e02cab
--- /dev/null
+++ b/internal/octant/link_generator.go
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package octant
+
+//go:generate mockgen -destination=./fake/mock_link_generator.go -package=fake . LinkGenerator
+
+// LinkGenerator is an interface containing object path items.
+type LinkGenerator interface {
+	// ObjectPath returns the path of a reference.
+	ObjectPath(namespace, apiVersion, kind, name string) (string, error)
+}
diff --git a/internal/octant/storage.go b/internal/octant/storage.go
new file mode 100644
index 00000000..c77a7950
--- /dev/null
+++ b/internal/octant/storage.go
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package octant
+
+import "github.com/vmware-tanzu/octant/pkg/store"
+
+//go:generate mockgen -destination=./fake/mock_storage.go -package=fake . Storage
+
+// Storage is an interface containing storage items.
+type Storage interface {
+	// ObjectStore returns the object store.
+	ObjectStore() store.Store
+}
diff --git a/pkg/dash/dash.go b/pkg/dash/dash.go
index a1e23931..bfa3db7c 100644
--- a/pkg/dash/dash.go
+++ b/pkg/dash/dash.go
@@ -261,7 +261,6 @@ func (r *Runner) initAPI(ctx context.Context, logger log.Logger, options Options
 	if err != nil {
 		return nil, nil, fmt.Errorf("initializing plugin manager: %w", err)
 	}
-	pluginManager.UpdateObjectStore(appObjectStore)
 
 	r.pluginManager = pluginManager
 
@@ -285,6 +284,8 @@ func (r *Runner) initAPI(ctx context.Context, logger log.Logger, options Options
 		restConfigOptions,
 		buildInfo)
 
+	pluginManager.SetOctantClient(dashConfig)
+
 	if err := watchConfigs(ctx, dashConfig, options.KubeConfig); err != nil {
 		return nil, nil, fmt.Errorf("set up config watcher: %w", err)
 	}
diff --git a/pkg/plugin/fake/mock_manager.go b/pkg/plugin/fake/mock_manager.go
index 123495c3..165694fe 100644
--- a/pkg/plugin/fake/mock_manager.go
+++ b/pkg/plugin/fake/mock_manager.go
@@ -12,7 +12,7 @@ import (
 	runtime "k8s.io/apimachinery/pkg/runtime"
 
 	plugin "github.com/vmware-tanzu/octant/pkg/plugin"
-	store "github.com/vmware-tanzu/octant/pkg/store"
+	javascript "github.com/vmware-tanzu/octant/pkg/plugin/javascript"
 	component "github.com/vmware-tanzu/octant/pkg/view/component"
 )
 
@@ -69,6 +69,18 @@ func (mr *MockManagerInterfaceMockRecorder) Print(arg0, arg1 interface{}) *gomoc
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Print", reflect.TypeOf((*MockManagerInterface)(nil).Print), arg0, arg1)
 }
 
+// SetOctantClient mocks base method
+func (m *MockManagerInterface) SetOctantClient(arg0 javascript.OctantClient) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetOctantClient", arg0)
+}
+
+// SetOctantClient indicates an expected call of SetOctantClient
+func (mr *MockManagerInterfaceMockRecorder) SetOctantClient(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetOctantClient", reflect.TypeOf((*MockManagerInterface)(nil).SetOctantClient), arg0)
+}
+
 // Store mocks base method
 func (m *MockManagerInterface) Store() plugin.ManagerStore {
 	m.ctrl.T.Helper()
@@ -97,15 +109,3 @@ func (mr *MockManagerInterfaceMockRecorder) Tabs(arg0, arg1 interface{}) *gomock
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Tabs", reflect.TypeOf((*MockManagerInterface)(nil).Tabs), arg0, arg1)
 }
-
-// UpdateObjectStore mocks base method
-func (m *MockManagerInterface) UpdateObjectStore(arg0 store.Store) {
-	m.ctrl.T.Helper()
-	m.ctrl.Call(m, "UpdateObjectStore", arg0)
-}
-
-// UpdateObjectStore indicates an expected call of UpdateObjectStore
-func (mr *MockManagerInterfaceMockRecorder) UpdateObjectStore(arg0 interface{}) *gomock.Call {
-	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateObjectStore", reflect.TypeOf((*MockManagerInterface)(nil).UpdateObjectStore), arg0)
-}
diff --git a/pkg/plugin/javascript.go b/pkg/plugin/javascript.go
index 479073ce..469603b7 100644
--- a/pkg/plugin/javascript.go
+++ b/pkg/plugin/javascript.go
@@ -17,12 +17,12 @@ import (
 	"github.com/dop251/goja_nodejs/eventloop"
 	"k8s.io/apimachinery/pkg/runtime"
 
+	"github.com/vmware-tanzu/octant/internal/octant"
 	"github.com/vmware-tanzu/octant/pkg/plugin/javascript"
 
 	"github.com/vmware-tanzu/octant/pkg/action"
 	"github.com/vmware-tanzu/octant/pkg/log"
 	"github.com/vmware-tanzu/octant/pkg/navigation"
-	"github.com/vmware-tanzu/octant/pkg/store"
 	"github.com/vmware-tanzu/octant/pkg/view/component"
 )
 
@@ -89,16 +89,15 @@ type jsPlugin struct {
 	classExtractor    JSClassExtractor
 	metadataExtractor JSMetadataExtractor
 
-	objectStore store.Store
-	mu          sync.Mutex
-	ctx         context.Context
-	logger      log.Logger
+	mu     sync.Mutex
+	ctx    context.Context
+	logger log.Logger
 }
 
 var _ JSPlugin = (*jsPlugin)(nil)
 
 // NewJSPlugin creates a new instances of a JavaScript plugin.
-func NewJSPlugin(ctx context.Context, objectStore store.Store, pluginPath string, options ...JSOption) (*jsPlugin, error) {
+func NewJSPlugin(ctx context.Context, pluginPath string, dashboardClientFactory octant.DashboardClientFactory, options ...JSOption) (*jsPlugin, error) {
 	plugin := &jsPlugin{
 		ctx:               ctx,
 		pluginPath:        pluginPath,
@@ -138,6 +137,10 @@ func NewJSPlugin(ctx context.Context, objectStore store.Store, pluginPath string
 			errCh <- fmt.Errorf("script execution: %w", err)
 		}
 
+		// Convert these to use require.RegisterNativeModule
+		vm.Set("httpClient", javascript.CreateHTTPClientObject(vm, pluginClass))
+		vm.Set("dashboardClient", dashboardClientFactory.Create(ctx, vm))
+
 		pluginClass, err = plugin.classExtractor(vm)
 		if err != nil {
 			errCh <- fmt.Errorf("loading pluginClass: %w", err)
@@ -148,17 +151,13 @@ func NewJSPlugin(ctx context.Context, objectStore store.Store, pluginPath string
 			errCh <- fmt.Errorf("loading metadata: %w", err)
 		}
 
-		// Convert these to use require.RegisterNativeModule
-		vm.Set("httpClient", javascript.CreateHTTPClientObject(vm, pluginClass))
-		vm.Set("dashboardClient", javascript.CreateDashClientObject(ctx, objectStore, vm))
-
 		errCh <- nil
 
 	})
 
 	err = <-errCh
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("javascript loop: %w", err)
 	}
 
 	plugin.loop = loop
diff --git a/pkg/plugin/javascript/conversion.go b/pkg/plugin/javascript/conversion.go
index 0801f6f2..da51263a 100644
--- a/pkg/plugin/javascript/conversion.go
+++ b/pkg/plugin/javascript/conversion.go
@@ -26,13 +26,13 @@ func ConvertToComponent(name string, i interface{}) (component.Component, error)
 		return nil, fmt.Errorf("unable to get metadata from %s", name)
 	}
 
-	metadataJson, err := json.Marshal(rawMetadata)
+	metadataJSON, err := json.Marshal(rawMetadata)
 	if err != nil {
 		return nil, fmt.Errorf("unable to marshal metadata from: %s: %w", name, err)
 	}
 
 	metadata := component.Metadata{}
-	if err := json.Unmarshal(metadataJson, &metadata); err != nil {
+	if err := json.Unmarshal(metadataJSON, &metadata); err != nil {
 		return nil, fmt.Errorf("unable to unmarhal metadata from %s: %w", name, err)
 	}
 
@@ -41,13 +41,13 @@ func ConvertToComponent(name string, i interface{}) (component.Component, error)
 		return nil, fmt.Errorf("unable to get config from %s", name)
 	}
 
-	configJson, err := json.Marshal(config)
+	configJSON, err := json.Marshal(config)
 	if err != nil {
 		return nil, fmt.Errorf("unable to marshal buttonGroup config: %w", err)
 	}
 
 	typedObject := component.TypedObject{
-		Config:   configJson,
+		Config:   configJSON,
 		Metadata: metadata,
 	}
 
@@ -86,7 +86,7 @@ func ConvertToItems(name string, i interface{}) ([]component.FlexLayoutItem, err
 	return items, nil
 }
 
-// ConvertToSection attempts to convert interface i to a list of SummarySection.
+// ConvertToSections attempts to convert interface i to a list of SummarySection.
 func ConvertToSections(name string, i interface{}) ([]component.SummarySection, error) {
 	var sections []component.SummarySection
 
diff --git a/pkg/plugin/javascript/dashboard_client.go b/pkg/plugin/javascript/dashboard_client.go
new file mode 100644
index 00000000..3bd5ff9e
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_client.go
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/dop251/goja"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+)
+
+// ModularDashboardClientFactory is a modular octant.DashboardClientFactory. It configures
+// itself based on functions passed when it is initialized.
+type ModularDashboardClientFactory struct {
+	functions []octant.DashboardClientFunction
+}
+
+var _ octant.DashboardClientFactory = &ModularDashboardClientFactory{}
+
+// NewModularDashboardClientFactory creates an instance of ModularDashboardClientFactory.
+func NewModularDashboardClientFactory(functions []octant.DashboardClientFunction) *ModularDashboardClientFactory {
+	m := &ModularDashboardClientFactory{
+		functions: functions,
+	}
+	return m
+}
+
+// Create creates a dashboard client javascript value.
+func (m *ModularDashboardClientFactory) Create(ctx context.Context, vm *goja.Runtime) goja.Value {
+	obj := vm.NewObject()
+
+	for _, fn := range m.functions {
+		if err := obj.Set(fn.Name(), fn.Call(ctx, vm)); err != nil {
+			return vm.NewGoError(err)
+		}
+	}
+
+	return obj
+}
+
+// OctantClient is a client for interacting with Octant.
+type OctantClient interface {
+	octant.LinkGenerator
+	octant.Storage
+}
+
+// DefaultFunctions are the default functions for the ModularDashboardClientFactory.
+func DefaultFunctions(octantClient OctantClient) []octant.DashboardClientFunction {
+	return []octant.DashboardClientFunction{
+		NewDashboardGet(octantClient),
+		NewDashboardList(octantClient),
+		NewDashboardUpdate(octantClient),
+		NewDashboardDelete(octantClient),
+		NewDashboardRefPath(octantClient),
+	}
+}
+
+// panicMessage creates a message for a panic given an error and an optional reason.
+// If the reason is blank, it will be omitted.
+func panicMessage(vm *goja.Runtime, err error, reason string) goja.Value {
+	if reason == "" {
+		return vm.ToValue(err.Error())
+	}
+
+	return vm.ToValue(fmt.Sprintf("%s: %s", reason, err.Error()))
+}
diff --git a/pkg/plugin/javascript/dashboard_delete.go b/pkg/plugin/javascript/dashboard_delete.go
new file mode 100644
index 00000000..7a50a3c4
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_delete.go
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+
+	"github.com/dop251/goja"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/pkg/store"
+)
+
+// DashboardDelete is a function for deleting an object by key.
+type DashboardDelete struct {
+	storage octant.Storage
+}
+
+var _ octant.DashboardClientFunction = &DashboardDelete{}
+
+// NewDashboardDelete creates an instance of DashboardDelete.
+func NewDashboardDelete(storage octant.Storage) *DashboardDelete {
+	d := &DashboardDelete{
+		storage: storage,
+	}
+	return d
+}
+
+// Name returns the name of this function. It will always return "Delete".
+func (d *DashboardDelete) Name() string {
+	return "Delete"
+}
+
+// Call creates a function call that deletes an object by key. If the key is invalid, or if the
+// delete is unsuccessful, it will throw a javascript exception.
+func (d *DashboardDelete) Call(ctx context.Context, vm *goja.Runtime) func(c goja.FunctionCall) goja.Value {
+	return func(c goja.FunctionCall) goja.Value {
+		var key store.Key
+		obj := c.Argument(0).ToObject(vm)
+
+		// This will never error since &key is a pointer to a type.
+		_ = vm.ExportTo(obj, &key)
+
+		if err := d.storage.ObjectStore().Delete(ctx, key); err != nil {
+			panic(panicMessage(vm, err, ""))
+		}
+		return goja.Undefined()
+	}
+}
diff --git a/pkg/plugin/javascript/dashboard_get.go b/pkg/plugin/javascript/dashboard_get.go
new file mode 100644
index 00000000..a125ae7e
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_get.go
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+
+	"github.com/dop251/goja"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/pkg/store"
+)
+
+// DashboardGet is a function that gets an object by key.
+type DashboardGet struct {
+	storage octant.Storage
+}
+
+var _ octant.DashboardClientFunction = &DashboardGet{}
+
+// NewDashboardGet creates an instance of DashboardGet.
+func NewDashboardGet(storage octant.Storage) *DashboardGet {
+	d := &DashboardGet{
+		storage: storage,
+	}
+	return d
+}
+
+// Name returns the name of this function. It will always return "Get".
+func (d *DashboardGet) Name() string {
+	return "Get"
+}
+
+// Call creates a function call that gets an object by key. If the key is invalid, or if the
+// get is unsuccessful, it will throw a javascript exception.
+func (d *DashboardGet) Call(ctx context.Context, vm *goja.Runtime) func(c goja.FunctionCall) goja.Value {
+	return func(c goja.FunctionCall) goja.Value {
+		var key store.Key
+		obj := c.Argument(0).ToObject(vm)
+
+		// This will never error since &key is a pointer to a type.
+		_ = vm.ExportTo(obj, &key)
+
+		u, err := d.storage.ObjectStore().Get(ctx, key)
+		if err != nil {
+			panic(panicMessage(vm, err, ""))
+		}
+
+		return vm.ToValue(u.Object)
+	}
+}
diff --git a/pkg/plugin/javascript/dashboard_list.go b/pkg/plugin/javascript/dashboard_list.go
new file mode 100644
index 00000000..407a515e
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_list.go
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+
+	"github.com/dop251/goja"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/pkg/store"
+)
+
+// DashboardList is a function that lists objects by key.
+type DashboardList struct {
+	storage octant.Storage
+}
+
+var _ octant.DashboardClientFunction = &DashboardList{}
+
+// NewDashboardList creates an instance of DashboardList.
+func NewDashboardList(storage octant.Storage) *DashboardList {
+	d := &DashboardList{
+		storage: storage,
+	}
+	return d
+}
+
+// Name returns the name of this function. It will always return "List".
+func (d *DashboardList) Name() string {
+	return "List"
+}
+
+// Call creates a function call that lists objects by key. If the key is invalid, or if the
+// list is unsuccessful, it will throw a javascript exception.
+func (d *DashboardList) Call(ctx context.Context, vm *goja.Runtime) func(c goja.FunctionCall) goja.Value {
+	return func(c goja.FunctionCall) goja.Value {
+		var key store.Key
+		obj := c.Argument(0).ToObject(vm)
+
+		// This will never error since &key is a pointer to a type.
+		_ = vm.ExportTo(obj, &key)
+
+		u, _, err := d.storage.ObjectStore().List(ctx, key)
+		if err != nil {
+			panic(panicMessage(vm, err, ""))
+		}
+
+		items := make([]interface{}, len(u.Items))
+		for i := 0; i < len(u.Items); i++ {
+			items[i] = u.Items[i].Object
+		}
+
+		return vm.ToValue(items)
+	}
+}
diff --git a/pkg/plugin/javascript/dashboard_ref_path.go b/pkg/plugin/javascript/dashboard_ref_path.go
new file mode 100644
index 00000000..4a412647
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_ref_path.go
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+
+	"github.com/dop251/goja"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+)
+
+// DashboardRefPath is a function that returns the path for a ref.
+type DashboardRefPath struct {
+	linkGenerator octant.LinkGenerator
+}
+
+var _ octant.DashboardClientFunction = &DashboardRefPath{}
+
+// NewDashboardRefPath creates an instance of DashboardRefPath.
+func NewDashboardRefPath(linkGenerator octant.LinkGenerator) *DashboardRefPath {
+	d := &DashboardRefPath{
+		linkGenerator: linkGenerator,
+	}
+	return d
+}
+
+// Name returns the name of this function. It will always return "RefPath".
+func (d *DashboardRefPath) Name() string {
+	return "RefPath"
+}
+
+type ref struct {
+	Namespace  string `json:"namespace"`
+	APIVersion string `json:"apiVersion"`
+	Kind       string `json:"kind"`
+	Name       string `json:"name"`
+}
+
+// Call create a function call generates a path for a ref. If the operation is unsuccessful,
+// it will throw a javascript exception.
+func (d *DashboardRefPath) Call(_ context.Context, vm *goja.Runtime) func(c goja.FunctionCall) goja.Value {
+	return func(c goja.FunctionCall) goja.Value {
+		obj := c.Argument(0).ToObject(vm)
+
+		var r ref
+
+		// This will never error since &ref is a pointer to a type.
+		_ = vm.ExportTo(obj, &r)
+
+		p, err := d.linkGenerator.ObjectPath(r.Namespace, r.APIVersion, r.Kind, r.Name)
+		if err != nil {
+			panic(panicMessage(vm, err, "dashboardClient.RefPath"))
+		}
+
+		return vm.ToValue(p)
+	}
+}
diff --git a/pkg/plugin/javascript/dashboard_update.go b/pkg/plugin/javascript/dashboard_update.go
new file mode 100644
index 00000000..8faa30b9
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_update.go
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+	"errors"
+
+	"github.com/dop251/goja"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+)
+
+// DashboardUpdate is a function that updates YAML. The text can send one
+// or more objects.
+type DashboardUpdate struct {
+	storage octant.Storage
+}
+
+var _ octant.DashboardClientFunction = &DashboardUpdate{}
+
+// NewDashboardUpdate creates an instance of DashboardUpdate.
+func NewDashboardUpdate(storage octant.Storage) *DashboardUpdate {
+	d := &DashboardUpdate{
+		storage: storage,
+	}
+	return d
+}
+
+// Name returns the name of this function. It will always return "Update".
+func (d *DashboardUpdate) Name() string {
+	return "Update"
+}
+
+// Call create a function call that sends YAML to the Kubernetes cluster. If the the cluster
+// rejects the YAML, it will throw a javascript exception.
+func (d *DashboardUpdate) Call(ctx context.Context, vm *goja.Runtime) func(c goja.FunctionCall) goja.Value {
+	return func(c goja.FunctionCall) goja.Value {
+		namespace := c.Argument(0).String()
+		update := c.Argument(1).String()
+
+		results, err := d.storage.ObjectStore().CreateOrUpdateFromYAML(ctx, namespace, update)
+		if err != nil {
+			panic(panicMessage(vm, errors.New("no YAML was supplied"), ""))
+		}
+
+		return vm.ToValue(results)
+	}
+}
diff --git a/pkg/plugin/javascript/dashclient.go b/pkg/plugin/javascript/dashclient.go
deleted file mode 100644
index ab9a28f2..00000000
--- a/pkg/plugin/javascript/dashclient.go
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
-Copyright (c) 2020 the Octant contributors. All Rights Reserved.
-SPDX-License-Identifier: Apache-2.0
-*/
-
-package javascript
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/dop251/goja"
-
-	"github.com/vmware-tanzu/octant/pkg/store"
-)
-
-type dashboardClient struct {
-	objectStore store.Store
-	vm          *goja.Runtime
-	ctx         context.Context
-}
-
-// CreateDashClientObject creates an object that wraps the Octant object store
-// and exposes them as methods to be used in the JavaScript runtime.
-func CreateDashClientObject(ctx context.Context, objStore store.Store, vm *goja.Runtime) goja.Value {
-	d := dashboardClient{
-		ctx:         ctx,
-		objectStore: objStore,
-		vm:          vm,
-	}
-
-	obj := d.vm.NewObject()
-	if err := obj.Set("Get", d.Get); err != nil {
-		return d.vm.NewGoError(err)
-	}
-	if err := obj.Set("List", d.List); err != nil {
-		return d.vm.NewGoError(err)
-	}
-	if err := obj.Set("Create", d.Create); err != nil {
-		return d.vm.NewGoError(err)
-	}
-	if err := obj.Set("Update", d.Create); err != nil {
-		return d.vm.NewGoError(err)
-	}
-	if err := obj.Set("Delete", d.Delete); err != nil {
-		return d.vm.NewGoError(err)
-	}
-	return obj
-}
-
-// Delete deletes an object.
-func (d *dashboardClient) Delete(c goja.FunctionCall) goja.Value {
-	var key store.Key
-	obj := c.Argument(0).ToObject(d.vm)
-	if err := d.vm.ExportTo(obj, &key); err != nil {
-		return d.vm.NewTypeError(fmt.Errorf("dashboardClient.Delete: %w", err))
-	}
-
-	if err := d.objectStore.Delete(d.ctx, key); err != nil {
-		return d.vm.NewGoError(err)
-	}
-	return goja.Undefined()
-}
-
-// Get retrieves a single object.
-func (d *dashboardClient) Get(c goja.FunctionCall) goja.Value {
-	var key store.Key
-	obj := c.Argument(0).ToObject(d.vm)
-	if err := d.vm.ExportTo(obj, &key); err != nil {
-		return d.vm.NewGoError(fmt.Errorf("dashboardClient.Get: %w", err))
-	}
-
-	u, err := d.objectStore.Get(d.ctx, key)
-	if err != nil {
-		return d.vm.NewGoError(err)
-	}
-
-	return d.vm.ToValue(u.Object)
-}
-
-// List returns a list of objects.
-func (d *dashboardClient) List(c goja.FunctionCall) goja.Value {
-	var key store.Key
-	obj := c.Argument(0).ToObject(d.vm)
-	if err := d.vm.ExportTo(obj, &key); err != nil {
-		return d.vm.NewGoError(fmt.Errorf("dashboardClient.List: %w", err))
-	}
-
-	u, _, err := d.objectStore.List(d.ctx, key)
-	if err != nil {
-		return d.vm.NewGoError(err)
-	}
-
-	items := make([]interface{}, len(u.Items))
-	for i := 0; i < len(u.Items); i++ {
-		items[i] = u.Items[i].Object
-	}
-
-	return d.vm.ToValue(items)
-}
-
-// Create creates a new object.
-func (d *dashboardClient) Create(c goja.FunctionCall) goja.Value {
-	namespace := c.Argument(0).String()
-	update := c.Argument(1).String()
-
-	if namespace == "" {
-		return d.vm.NewTypeError(fmt.Errorf("create/update: invalid namespace"))
-	}
-
-	if update == "" {
-		return d.vm.NewTypeError(fmt.Errorf("create/update: empty yaml"))
-	}
-
-	results, err := d.objectStore.CreateOrUpdateFromYAML(d.ctx, namespace, update)
-	if err != nil {
-		return d.vm.NewTypeError(fmt.Errorf("create/update: %w", err))
-	}
-
-	return d.vm.ToValue(results)
-}
diff --git a/pkg/plugin/manager.go b/pkg/plugin/manager.go
index 03d9333e..502c6de4 100644
--- a/pkg/plugin/manager.go
+++ b/pkg/plugin/manager.go
@@ -18,11 +18,11 @@ import (
 	"sync"
 	"time"
 
-	"github.com/vmware-tanzu/octant/pkg/store"
-
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/client-go/dynamic"
 
+	"github.com/vmware-tanzu/octant/pkg/plugin/javascript"
+
 	"github.com/fsnotify/fsnotify"
 	"github.com/hashicorp/go-plugin"
 	"github.com/pkg/errors"
@@ -239,8 +239,8 @@ type ManagerInterface interface {
 	// ObjectStatus returns the object status
 	ObjectStatus(ctx context.Context, object runtime.Object) (*ObjectStatusResponse, error)
 
-	// UpdateClusterClient sets the current cluster client.
-	UpdateObjectStore(objectStore store.Store)
+	// SetOctantClient sets the the Octant client.
+	SetOctantClient(octantClient javascript.OctantClient)
 }
 
 // ModuleRegistrar is a module registrar.
@@ -272,9 +272,9 @@ type Manager struct {
 
 	Runners Runners
 
-	objectStore store.Store
-	configs     []config
-	store       ManagerStore
+	octantClient javascript.OctantClient
+	configs      []config
+	store        ManagerStore
 
 	lock sync.Mutex
 }
@@ -299,10 +299,11 @@ func NewManager(apiService api.API, moduleRegistrar ModuleRegistrar, actionRegis
 	return m
 }
 
-func (m *Manager) UpdateObjectStore(objectStore store.Store) {
+func (m *Manager) SetOctantClient(client javascript.OctantClient) {
 	m.lock.Lock()
 	defer m.lock.Unlock()
-	m.objectStore = objectStore
+
+	m.octantClient = client
 }
 
 // Store returns the store for the manager.
@@ -376,7 +377,7 @@ func (m *Manager) watchJS(ctx context.Context) {
 			m.store.RemoveJS(name)
 		}
 		logger.Infof("reloading: JavaScript plugin: %s", name)
-		if err := m.registerJSPlugin(ctx, name, m.API.Addr()); err != nil {
+		if err := m.registerJSPlugin(ctx, name); err != nil {
 			logger.Errorf("reloading: JavaScript plugin watcher: %w", err)
 		}
 	}
@@ -443,8 +444,10 @@ func (m *Manager) unregisterJSPlugin(_ context.Context, p JSPlugin) error {
 	return nil
 }
 
-func (m *Manager) registerJSPlugin(ctx context.Context, pluginPath string, apiAddr string) error {
-	jsPlugin, err := NewJSPlugin(ctx, m.objectStore, pluginPath)
+func (m *Manager) registerJSPlugin(ctx context.Context, pluginPath string) error {
+	dashboardClientFactory := javascript.NewModularDashboardClientFactory(javascript.DefaultFunctions(m.octantClient))
+
+	jsPlugin, err := NewJSPlugin(ctx, pluginPath, dashboardClientFactory)
 	if err != nil {
 		return err
 	}
@@ -488,7 +491,7 @@ func (m *Manager) registerJSPlugin(ctx context.Context, pluginPath string, apiAd
 	return nil
 }
 
-func (m *Manager) startJS(ctx context.Context, apiAddr string) error {
+func (m *Manager) startJS(ctx context.Context) error {
 	pluginList, err := AvailablePlugins(DefaultConfig)
 	if err != nil {
 		return err
@@ -497,9 +500,9 @@ func (m *Manager) startJS(ctx context.Context, apiAddr string) error {
 	for _, pluginPath := range pluginList {
 		if IsJavaScriptPlugin(pluginPath) {
 			logger := log.From(ctx)
-			logger.With("addr", apiAddr).Debugf("creating ts plugin client")
+			logger.Debugf("creating ts plugin client")
 
-			if err := m.registerJSPlugin(ctx, pluginPath, apiAddr); err != nil {
+			if err := m.registerJSPlugin(ctx, pluginPath); err != nil {
 				return fmt.Errorf("javascript plugin: %w", err)
 			}
 		}
@@ -528,7 +531,7 @@ func (m *Manager) Start(ctx context.Context) error {
 	m.lock.Lock()
 	defer m.lock.Unlock()
 
-	if err := m.startJS(ctx, m.API.Addr()); err != nil {
+	if err := m.startJS(ctx); err != nil {
 		return err
 	}
 
diff --git a/pkg/store/store.go b/pkg/store/store.go
index 043700d7..fe378276 100644
--- a/pkg/store/store.go
+++ b/pkg/store/store.go
@@ -7,9 +7,11 @@ package store
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"strings"
 
+	"github.com/hashicorp/go-multierror"
 	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/labels"
@@ -38,6 +40,10 @@ type Store interface {
 	Update(ctx context.Context, key Key, updater func(*unstructured.Unstructured) error) error
 	IsLoading(ctx context.Context, key Key) bool
 	Create(ctx context.Context, object *unstructured.Unstructured) error
+	// CreateOrUpdateFromYAML creates resources in the cluster from YAML input.
+	// Resources are created in the order they are present in the YAML.
+	// An error creating a resource halts resource creation.
+	// A list of created resources is returned. You may have created resources AND a non-nil error.
 	CreateOrUpdateFromYAML(ctx context.Context, namespace, input string) ([]string, error)
 }
 
@@ -50,6 +56,21 @@ type Key struct {
 	Selector   *labels.Set `json:"selector"`
 }
 
+// Validate validates the key.
+func (k Key) Validate() error {
+	var err error
+
+	if k.APIVersion == "" {
+		err = multierror.Append(err, errors.New("apiVersion is blank"))
+	}
+
+	if k.Kind == "" {
+		err = multierror.Append(err, errors.New("kind is blank"))
+	}
+
+	return err
+}
+
 func (k Key) String() string {
 	var sb strings.Builder
 
diff --git a/vendor/github.com/hashicorp/errwrap/LICENSE b/vendor/github.com/hashicorp/errwrap/LICENSE
new file mode 100644
index 00000000..c33dcc7c
--- /dev/null
+++ b/vendor/github.com/hashicorp/errwrap/LICENSE
@@ -0,0 +1,354 @@
+Mozilla Public License, version 2.0
+
+1. Definitions
+
+1.1. “Contributor”
+
+     means each individual or legal entity that creates, contributes to the
+     creation of, or owns Covered Software.
+
+1.2. “Contributor Version”
+
+     means the combination of the Contributions of others (if any) used by a
+     Contributor and that particular Contributor’s Contribution.
+
+1.3. “Contribution”
+
+     means Covered Software of a particular Contributor.
+
+1.4. “Covered Software”
+
+     means Source Code Form to which the initial Contributor has attached the
+     notice in Exhibit A, the Executable Form of such Source Code Form, and
+     Modifications of such Source Code Form, in each case including portions
+     thereof.
+
+1.5. “Incompatible With Secondary Licenses”
+     means
+
+     a. that the initial Contributor has attached the notice described in
+        Exhibit B to the Covered Software; or
+
+     b. that the Covered Software was made available under the terms of version
+        1.1 or earlier of the License, but not also under the terms of a
+        Secondary License.
+
+1.6. “Executable Form”
+
+     means any form of the work other than Source Code Form.
+
+1.7. “Larger Work”
+
+     means a work that combines Covered Software with other material, in a separate
+     file or files, that is not Covered Software.
+
+1.8. “License”
+
+     means this document.
+
+1.9. “Licensable”
+
+     means having the right to grant, to the maximum extent possible, whether at the
+     time of the initial grant or subsequently, any and all of the rights conveyed by
+     this License.
+
+1.10. “Modifications”
+
+     means any of the following:
+
+     a. any file in Source Code Form that results from an addition to, deletion
+        from, or modification of the contents of Covered Software; or
+
+     b. any new file in Source Code Form that contains any Covered Software.
+
+1.11. “Patent Claims” of a Contributor
+
+      means any patent claim(s), including without limitation, method, process,
+      and apparatus claims, in any patent Licensable by such Contributor that
+      would be infringed, but for the grant of the License, by the making,
+      using, selling, offering for sale, having made, import, or transfer of
+      either its Contributions or its Contributor Version.
+
+1.12. “Secondary License”
+
+      means either the GNU General Public License, Version 2.0, the GNU Lesser
+      General Public License, Version 2.1, the GNU Affero General Public
+      License, Version 3.0, or any later versions of those licenses.
+
+1.13. “Source Code Form”
+
+      means the form of the work preferred for making modifications.
+
+1.14. “You” (or “Your”)
+
+      means an individual or a legal entity exercising rights under this
+      License. For legal entities, “You” includes any entity that controls, is
+      controlled by, or is under common control with You. For purposes of this
+      definition, “control” means (a) the power, direct or indirect, to cause
+      the direction or management of such entity, whether by contract or
+      otherwise, or (b) ownership of more than fifty percent (50%) of the
+      outstanding shares or beneficial ownership of such entity.
+
+
+2. License Grants and Conditions
+
+2.1. Grants
+
+     Each Contributor hereby grants You a world-wide, royalty-free,
+     non-exclusive license:
+
+     a. under intellectual property rights (other than patent or trademark)
+        Licensable by such Contributor to use, reproduce, make available,
+        modify, display, perform, distribute, and otherwise exploit its
+        Contributions, either on an unmodified basis, with Modifications, or as
+        part of a Larger Work; and
+
+     b. under Patent Claims of such Contributor to make, use, sell, offer for
+        sale, have made, import, and otherwise transfer either its Contributions
+        or its Contributor Version.
+
+2.2. Effective Date
+
+     The licenses granted in Section 2.1 with respect to any Contribution become
+     effective for each Contribution on the date the Contributor first distributes
+     such Contribution.
+
+2.3. Limitations on Grant Scope
+
+     The licenses granted in this Section 2 are the only rights granted under this
+     License. No additional rights or licenses will be implied from the distribution
+     or licensing of Covered Software under this License. Notwithstanding Section
+     2.1(b) above, no patent license is granted by a Contributor:
+
+     a. for any code that a Contributor has removed from Covered Software; or
+
+     b. for infringements caused by: (i) Your and any other third party’s
+        modifications of Covered Software, or (ii) the combination of its
+        Contributions with other software (except as part of its Contributor
+        Version); or
+
+     c. under Patent Claims infringed by Covered Software in the absence of its
+        Contributions.
+
+     This License does not grant any rights in the trademarks, service marks, or
+     logos of any Contributor (except as may be necessary to comply with the
+     notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+     No Contributor makes additional grants as a result of Your choice to
+     distribute the Covered Software under a subsequent version of this License
+     (see Section 10.2) or under the terms of a Secondary License (if permitted
+     under the terms of Section 3.3).
+
+2.5. Representation
+
+     Each Contributor represents that the Contributor believes its Contributions
+     are its original creation(s) or it has sufficient rights to grant the
+     rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+     This License is not intended to limit any rights You have under applicable
+     copyright doctrines of fair use, fair dealing, or other equivalents.
+
+2.7. Conditions
+
+     Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in
+     Section 2.1.
+
+
+3. Responsibilities
+
+3.1. Distribution of Source Form
+
+     All distribution of Covered Software in Source Code Form, including any
+     Modifications that You create or to which You contribute, must be under the
+     terms of this License. You must inform recipients that the Source Code Form
+     of the Covered Software is governed by the terms of this License, and how
+     they can obtain a copy of this License. You may not attempt to alter or
+     restrict the recipients’ rights in the Source Code Form.
+
+3.2. Distribution of Executable Form
+
+     If You distribute Covered Software in Executable Form then:
+
+     a. such Covered Software must also be made available in Source Code Form,
+        as described in Section 3.1, and You must inform recipients of the
+        Executable Form how they can obtain a copy of such Source Code Form by
+        reasonable means in a timely manner, at a charge no more than the cost
+        of distribution to the recipient; and
+
+     b. You may distribute such Executable Form under the terms of this License,
+        or sublicense it under different terms, provided that the license for
+        the Executable Form does not attempt to limit or alter the recipients’
+        rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+     You may create and distribute a Larger Work under terms of Your choice,
+     provided that You also comply with the requirements of this License for the
+     Covered Software. If the Larger Work is a combination of Covered Software
+     with a work governed by one or more Secondary Licenses, and the Covered
+     Software is not Incompatible With Secondary Licenses, this License permits
+     You to additionally distribute such Covered Software under the terms of
+     such Secondary License(s), so that the recipient of the Larger Work may, at
+     their option, further distribute the Covered Software under the terms of
+     either this License or such Secondary License(s).
+
+3.4. Notices
+
+     You may not remove or alter the substance of any license notices (including
+     copyright notices, patent notices, disclaimers of warranty, or limitations
+     of liability) contained within the Source Code Form of the Covered
+     Software, except that You may alter any license notices to the extent
+     required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+     You may choose to offer, and to charge a fee for, warranty, support,
+     indemnity or liability obligations to one or more recipients of Covered
+     Software. However, You may do so only on Your own behalf, and not on behalf
+     of any Contributor. You must make it absolutely clear that any such
+     warranty, support, indemnity, or liability obligation is offered by You
+     alone, and You hereby agree to indemnify every Contributor for any
+     liability incurred by such Contributor as a result of warranty, support,
+     indemnity or liability terms You offer. You may include additional
+     disclaimers of warranty and limitations of liability specific to any
+     jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+
+   If it is impossible for You to comply with any of the terms of this License
+   with respect to some or all of the Covered Software due to statute, judicial
+   order, or regulation then You must: (a) comply with the terms of this License
+   to the maximum extent possible; and (b) describe the limitations and the code
+   they affect. Such description must be placed in a text file included with all
+   distributions of the Covered Software under this License. Except to the
+   extent prohibited by statute or regulation, such description must be
+   sufficiently detailed for a recipient of ordinary skill to be able to
+   understand it.
+
+5. Termination
+
+5.1. The rights granted under this License will terminate automatically if You
+     fail to comply with any of its terms. However, if You become compliant,
+     then the rights granted under this License from a particular Contributor
+     are reinstated (a) provisionally, unless and until such Contributor
+     explicitly and finally terminates Your grants, and (b) on an ongoing basis,
+     if such Contributor fails to notify You of the non-compliance by some
+     reasonable means prior to 60 days after You have come back into compliance.
+     Moreover, Your grants from a particular Contributor are reinstated on an
+     ongoing basis if such Contributor notifies You of the non-compliance by
+     some reasonable means, this is the first time You have received notice of
+     non-compliance with this License from such Contributor, and You become
+     compliant prior to 30 days after Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+     infringement claim (excluding declaratory judgment actions, counter-claims,
+     and cross-claims) alleging that a Contributor Version directly or
+     indirectly infringes any patent, then the rights granted to You by any and
+     all Contributors for the Covered Software under Section 2.1 of this License
+     shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user
+     license agreements (excluding distributors and resellers) which have been
+     validly granted by You or Your distributors under this License prior to
+     termination shall survive termination.
+
+6. Disclaimer of Warranty
+
+   Covered Software is provided under this License on an “as is” basis, without
+   warranty of any kind, either expressed, implied, or statutory, including,
+   without limitation, warranties that the Covered Software is free of defects,
+   merchantable, fit for a particular purpose or non-infringing. The entire
+   risk as to the quality and performance of the Covered Software is with You.
+   Should any Covered Software prove defective in any respect, You (not any
+   Contributor) assume the cost of any necessary servicing, repair, or
+   correction. This disclaimer of warranty constitutes an essential part of this
+   License. No use of  any Covered Software is authorized under this License
+   except under this disclaimer.
+
+7. Limitation of Liability
+
+   Under no circumstances and under no legal theory, whether tort (including
+   negligence), contract, or otherwise, shall any Contributor, or anyone who
+   distributes Covered Software as permitted above, be liable to You for any
+   direct, indirect, special, incidental, or consequential damages of any
+   character including, without limitation, damages for lost profits, loss of
+   goodwill, work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses, even if such party shall have been
+   informed of the possibility of such damages. This limitation of liability
+   shall not apply to liability for death or personal injury resulting from such
+   party’s negligence to the extent applicable law prohibits such limitation.
+   Some jurisdictions do not allow the exclusion or limitation of incidental or
+   consequential damages, so this exclusion and limitation may not apply to You.
+
+8. Litigation
+
+   Any litigation relating to this License may be brought only in the courts of
+   a jurisdiction where the defendant maintains its principal place of business
+   and such litigation shall be governed by laws of that jurisdiction, without
+   reference to its conflict-of-law provisions. Nothing in this Section shall
+   prevent a party’s ability to bring cross-claims or counter-claims.
+
+9. Miscellaneous
+
+   This License represents the complete agreement concerning the subject matter
+   hereof. If any provision of this License is held to be unenforceable, such
+   provision shall be reformed only to the extent necessary to make it
+   enforceable. Any law or regulation which provides that the language of a
+   contract shall be construed against the drafter shall not be used to construe
+   this License against a Contributor.
+
+
+10. Versions of the License
+
+10.1. New Versions
+
+      Mozilla Foundation is the license steward. Except as provided in Section
+      10.3, no one other than the license steward has the right to modify or
+      publish new versions of this License. Each version will be given a
+      distinguishing version number.
+
+10.2. Effect of New Versions
+
+      You may distribute the Covered Software under the terms of the version of
+      the License under which You originally received the Covered Software, or
+      under the terms of any subsequent version published by the license
+      steward.
+
+10.3. Modified Versions
+
+      If you create software not governed by this License, and you want to
+      create a new license for such software, you may create and use a modified
+      version of this License if you rename the license and remove any
+      references to the name of the license steward (except to note that such
+      modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses
+      If You choose to distribute Source Code Form that is Incompatible With
+      Secondary Licenses under the terms of this version of the License, the
+      notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+
+      This Source Code Form is subject to the
+      terms of the Mozilla Public License, v.
+      2.0. If a copy of the MPL was not
+      distributed with this file, You can
+      obtain one at
+      http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular file, then
+You may include the notice in a location (such as a LICENSE file in a relevant
+directory) where a recipient would be likely to look for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - “Incompatible With Secondary Licenses” Notice
+
+      This Source Code Form is “Incompatible
+      With Secondary Licenses”, as defined by
+      the Mozilla Public License, v. 2.0.
+
diff --git a/vendor/github.com/hashicorp/errwrap/README.md b/vendor/github.com/hashicorp/errwrap/README.md
new file mode 100644
index 00000000..444df08f
--- /dev/null
+++ b/vendor/github.com/hashicorp/errwrap/README.md
@@ -0,0 +1,89 @@
+# errwrap
+
+`errwrap` is a package for Go that formalizes the pattern of wrapping errors
+and checking if an error contains another error.
+
+There is a common pattern in Go of taking a returned `error` value and
+then wrapping it (such as with `fmt.Errorf`) before returning it. The problem
+with this pattern is that you completely lose the original `error` structure.
+
+Arguably the _correct_ approach is that you should make a custom structure
+implementing the `error` interface, and have the original error as a field
+on that structure, such [as this example](http://golang.org/pkg/os/#PathError).
+This is a good approach, but you have to know the entire chain of possible
+rewrapping that happens, when you might just care about one.
+
+`errwrap` formalizes this pattern (it doesn't matter what approach you use
+above) by giving a single interface for wrapping errors, checking if a specific
+error is wrapped, and extracting that error.
+
+## Installation and Docs
+
+Install using `go get github.com/hashicorp/errwrap`.
+
+Full documentation is available at
+http://godoc.org/github.com/hashicorp/errwrap
+
+## Usage
+
+#### Basic Usage
+
+Below is a very basic example of its usage:
+
+```go
+// A function that always returns an error, but wraps it, like a real
+// function might.
+func tryOpen() error {
+	_, err := os.Open("/i/dont/exist")
+	if err != nil {
+		return errwrap.Wrapf("Doesn't exist: {{err}}", err)
+	}
+
+	return nil
+}
+
+func main() {
+	err := tryOpen()
+
+	// We can use the Contains helpers to check if an error contains
+	// another error. It is safe to do this with a nil error, or with
+	// an error that doesn't even use the errwrap package.
+	if errwrap.Contains(err, "does not exist") {
+		// Do something
+	}
+	if errwrap.ContainsType(err, new(os.PathError)) {
+		// Do something
+	}
+
+	// Or we can use the associated `Get` functions to just extract
+	// a specific error. This would return nil if that specific error doesn't
+	// exist.
+	perr := errwrap.GetType(err, new(os.PathError))
+}
+```
+
+#### Custom Types
+
+If you're already making custom types that properly wrap errors, then
+you can get all the functionality of `errwraps.Contains` and such by
+implementing the `Wrapper` interface with just one function. Example:
+
+```go
+type AppError {
+  Code ErrorCode
+  Err  error
+}
+
+func (e *AppError) WrappedErrors() []error {
+  return []error{e.Err}
+}
+```
+
+Now this works:
+
+```go
+err := &AppError{Err: fmt.Errorf("an error")}
+if errwrap.ContainsType(err, fmt.Errorf("")) {
+	// This will work!
+}
+```
diff --git a/vendor/github.com/hashicorp/errwrap/errwrap.go b/vendor/github.com/hashicorp/errwrap/errwrap.go
new file mode 100644
index 00000000..a733bef1
--- /dev/null
+++ b/vendor/github.com/hashicorp/errwrap/errwrap.go
@@ -0,0 +1,169 @@
+// Package errwrap implements methods to formalize error wrapping in Go.
+//
+// All of the top-level functions that take an `error` are built to be able
+// to take any error, not just wrapped errors. This allows you to use errwrap
+// without having to type-check and type-cast everywhere.
+package errwrap
+
+import (
+	"errors"
+	"reflect"
+	"strings"
+)
+
+// WalkFunc is the callback called for Walk.
+type WalkFunc func(error)
+
+// Wrapper is an interface that can be implemented by custom types to
+// have all the Contains, Get, etc. functions in errwrap work.
+//
+// When Walk reaches a Wrapper, it will call the callback for every
+// wrapped error in addition to the wrapper itself. Since all the top-level
+// functions in errwrap use Walk, this means that all those functions work
+// with your custom type.
+type Wrapper interface {
+	WrappedErrors() []error
+}
+
+// Wrap defines that outer wraps inner, returning an error type that
+// can be cleanly used with the other methods in this package, such as
+// Contains, GetAll, etc.
+//
+// This function won't modify the error message at all (the outer message
+// will be used).
+func Wrap(outer, inner error) error {
+	return &wrappedError{
+		Outer: outer,
+		Inner: inner,
+	}
+}
+
+// Wrapf wraps an error with a formatting message. This is similar to using
+// `fmt.Errorf` to wrap an error. If you're using `fmt.Errorf` to wrap
+// errors, you should replace it with this.
+//
+// format is the format of the error message. The string '{{err}}' will
+// be replaced with the original error message.
+func Wrapf(format string, err error) error {
+	outerMsg := "<nil>"
+	if err != nil {
+		outerMsg = err.Error()
+	}
+
+	outer := errors.New(strings.Replace(
+		format, "{{err}}", outerMsg, -1))
+
+	return Wrap(outer, err)
+}
+
+// Contains checks if the given error contains an error with the
+// message msg. If err is not a wrapped error, this will always return
+// false unless the error itself happens to match this msg.
+func Contains(err error, msg string) bool {
+	return len(GetAll(err, msg)) > 0
+}
+
+// ContainsType checks if the given error contains an error with
+// the same concrete type as v. If err is not a wrapped error, this will
+// check the err itself.
+func ContainsType(err error, v interface{}) bool {
+	return len(GetAllType(err, v)) > 0
+}
+
+// Get is the same as GetAll but returns the deepest matching error.
+func Get(err error, msg string) error {
+	es := GetAll(err, msg)
+	if len(es) > 0 {
+		return es[len(es)-1]
+	}
+
+	return nil
+}
+
+// GetType is the same as GetAllType but returns the deepest matching error.
+func GetType(err error, v interface{}) error {
+	es := GetAllType(err, v)
+	if len(es) > 0 {
+		return es[len(es)-1]
+	}
+
+	return nil
+}
+
+// GetAll gets all the errors that might be wrapped in err with the
+// given message. The order of the errors is such that the outermost
+// matching error (the most recent wrap) is index zero, and so on.
+func GetAll(err error, msg string) []error {
+	var result []error
+
+	Walk(err, func(err error) {
+		if err.Error() == msg {
+			result = append(result, err)
+		}
+	})
+
+	return result
+}
+
+// GetAllType gets all the errors that are the same type as v.
+//
+// The order of the return value is the same as described in GetAll.
+func GetAllType(err error, v interface{}) []error {
+	var result []error
+
+	var search string
+	if v != nil {
+		search = reflect.TypeOf(v).String()
+	}
+	Walk(err, func(err error) {
+		var needle string
+		if err != nil {
+			needle = reflect.TypeOf(err).String()
+		}
+
+		if needle == search {
+			result = append(result, err)
+		}
+	})
+
+	return result
+}
+
+// Walk walks all the wrapped errors in err and calls the callback. If
+// err isn't a wrapped error, this will be called once for err. If err
+// is a wrapped error, the callback will be called for both the wrapper
+// that implements error as well as the wrapped error itself.
+func Walk(err error, cb WalkFunc) {
+	if err == nil {
+		return
+	}
+
+	switch e := err.(type) {
+	case *wrappedError:
+		cb(e.Outer)
+		Walk(e.Inner, cb)
+	case Wrapper:
+		cb(err)
+
+		for _, err := range e.WrappedErrors() {
+			Walk(err, cb)
+		}
+	default:
+		cb(err)
+	}
+}
+
+// wrappedError is an implementation of error that has both the
+// outer and inner errors.
+type wrappedError struct {
+	Outer error
+	Inner error
+}
+
+func (w *wrappedError) Error() string {
+	return w.Outer.Error()
+}
+
+func (w *wrappedError) WrappedErrors() []error {
+	return []error{w.Outer, w.Inner}
+}
diff --git a/vendor/github.com/hashicorp/errwrap/go.mod b/vendor/github.com/hashicorp/errwrap/go.mod
new file mode 100644
index 00000000..c9b84022
--- /dev/null
+++ b/vendor/github.com/hashicorp/errwrap/go.mod
@@ -0,0 +1,1 @@
+module github.com/hashicorp/errwrap
diff --git a/vendor/github.com/hashicorp/go-multierror/.travis.yml b/vendor/github.com/hashicorp/go-multierror/.travis.yml
new file mode 100644
index 00000000..304a8359
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/.travis.yml
@@ -0,0 +1,12 @@
+sudo: false
+
+language: go
+
+go:
+  - 1.x
+
+branches:
+  only:
+    - master
+
+script: make test testrace
diff --git a/vendor/github.com/hashicorp/go-multierror/LICENSE b/vendor/github.com/hashicorp/go-multierror/LICENSE
new file mode 100644
index 00000000..82b4de97
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/LICENSE
@@ -0,0 +1,353 @@
+Mozilla Public License, version 2.0
+
+1. Definitions
+
+1.1. “Contributor”
+
+     means each individual or legal entity that creates, contributes to the
+     creation of, or owns Covered Software.
+
+1.2. “Contributor Version”
+
+     means the combination of the Contributions of others (if any) used by a
+     Contributor and that particular Contributor’s Contribution.
+
+1.3. “Contribution”
+
+     means Covered Software of a particular Contributor.
+
+1.4. “Covered Software”
+
+     means Source Code Form to which the initial Contributor has attached the
+     notice in Exhibit A, the Executable Form of such Source Code Form, and
+     Modifications of such Source Code Form, in each case including portions
+     thereof.
+
+1.5. “Incompatible With Secondary Licenses”
+     means
+
+     a. that the initial Contributor has attached the notice described in
+        Exhibit B to the Covered Software; or
+
+     b. that the Covered Software was made available under the terms of version
+        1.1 or earlier of the License, but not also under the terms of a
+        Secondary License.
+
+1.6. “Executable Form”
+
+     means any form of the work other than Source Code Form.
+
+1.7. “Larger Work”
+
+     means a work that combines Covered Software with other material, in a separate
+     file or files, that is not Covered Software.
+
+1.8. “License”
+
+     means this document.
+
+1.9. “Licensable”
+
+     means having the right to grant, to the maximum extent possible, whether at the
+     time of the initial grant or subsequently, any and all of the rights conveyed by
+     this License.
+
+1.10. “Modifications”
+
+     means any of the following:
+
+     a. any file in Source Code Form that results from an addition to, deletion
+        from, or modification of the contents of Covered Software; or
+
+     b. any new file in Source Code Form that contains any Covered Software.
+
+1.11. “Patent Claims” of a Contributor
+
+      means any patent claim(s), including without limitation, method, process,
+      and apparatus claims, in any patent Licensable by such Contributor that
+      would be infringed, but for the grant of the License, by the making,
+      using, selling, offering for sale, having made, import, or transfer of
+      either its Contributions or its Contributor Version.
+
+1.12. “Secondary License”
+
+      means either the GNU General Public License, Version 2.0, the GNU Lesser
+      General Public License, Version 2.1, the GNU Affero General Public
+      License, Version 3.0, or any later versions of those licenses.
+
+1.13. “Source Code Form”
+
+      means the form of the work preferred for making modifications.
+
+1.14. “You” (or “Your”)
+
+      means an individual or a legal entity exercising rights under this
+      License. For legal entities, “You” includes any entity that controls, is
+      controlled by, or is under common control with You. For purposes of this
+      definition, “control” means (a) the power, direct or indirect, to cause
+      the direction or management of such entity, whether by contract or
+      otherwise, or (b) ownership of more than fifty percent (50%) of the
+      outstanding shares or beneficial ownership of such entity.
+
+
+2. License Grants and Conditions
+
+2.1. Grants
+
+     Each Contributor hereby grants You a world-wide, royalty-free,
+     non-exclusive license:
+
+     a. under intellectual property rights (other than patent or trademark)
+        Licensable by such Contributor to use, reproduce, make available,
+        modify, display, perform, distribute, and otherwise exploit its
+        Contributions, either on an unmodified basis, with Modifications, or as
+        part of a Larger Work; and
+
+     b. under Patent Claims of such Contributor to make, use, sell, offer for
+        sale, have made, import, and otherwise transfer either its Contributions
+        or its Contributor Version.
+
+2.2. Effective Date
+
+     The licenses granted in Section 2.1 with respect to any Contribution become
+     effective for each Contribution on the date the Contributor first distributes
+     such Contribution.
+
+2.3. Limitations on Grant Scope
+
+     The licenses granted in this Section 2 are the only rights granted under this
+     License. No additional rights or licenses will be implied from the distribution
+     or licensing of Covered Software under this License. Notwithstanding Section
+     2.1(b) above, no patent license is granted by a Contributor:
+
+     a. for any code that a Contributor has removed from Covered Software; or
+
+     b. for infringements caused by: (i) Your and any other third party’s
+        modifications of Covered Software, or (ii) the combination of its
+        Contributions with other software (except as part of its Contributor
+        Version); or
+
+     c. under Patent Claims infringed by Covered Software in the absence of its
+        Contributions.
+
+     This License does not grant any rights in the trademarks, service marks, or
+     logos of any Contributor (except as may be necessary to comply with the
+     notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+     No Contributor makes additional grants as a result of Your choice to
+     distribute the Covered Software under a subsequent version of this License
+     (see Section 10.2) or under the terms of a Secondary License (if permitted
+     under the terms of Section 3.3).
+
+2.5. Representation
+
+     Each Contributor represents that the Contributor believes its Contributions
+     are its original creation(s) or it has sufficient rights to grant the
+     rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+     This License is not intended to limit any rights You have under applicable
+     copyright doctrines of fair use, fair dealing, or other equivalents.
+
+2.7. Conditions
+
+     Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in
+     Section 2.1.
+
+
+3. Responsibilities
+
+3.1. Distribution of Source Form
+
+     All distribution of Covered Software in Source Code Form, including any
+     Modifications that You create or to which You contribute, must be under the
+     terms of this License. You must inform recipients that the Source Code Form
+     of the Covered Software is governed by the terms of this License, and how
+     they can obtain a copy of this License. You may not attempt to alter or
+     restrict the recipients’ rights in the Source Code Form.
+
+3.2. Distribution of Executable Form
+
+     If You distribute Covered Software in Executable Form then:
+
+     a. such Covered Software must also be made available in Source Code Form,
+        as described in Section 3.1, and You must inform recipients of the
+        Executable Form how they can obtain a copy of such Source Code Form by
+        reasonable means in a timely manner, at a charge no more than the cost
+        of distribution to the recipient; and
+
+     b. You may distribute such Executable Form under the terms of this License,
+        or sublicense it under different terms, provided that the license for
+        the Executable Form does not attempt to limit or alter the recipients’
+        rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+     You may create and distribute a Larger Work under terms of Your choice,
+     provided that You also comply with the requirements of this License for the
+     Covered Software. If the Larger Work is a combination of Covered Software
+     with a work governed by one or more Secondary Licenses, and the Covered
+     Software is not Incompatible With Secondary Licenses, this License permits
+     You to additionally distribute such Covered Software under the terms of
+     such Secondary License(s), so that the recipient of the Larger Work may, at
+     their option, further distribute the Covered Software under the terms of
+     either this License or such Secondary License(s).
+
+3.4. Notices
+
+     You may not remove or alter the substance of any license notices (including
+     copyright notices, patent notices, disclaimers of warranty, or limitations
+     of liability) contained within the Source Code Form of the Covered
+     Software, except that You may alter any license notices to the extent
+     required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+     You may choose to offer, and to charge a fee for, warranty, support,
+     indemnity or liability obligations to one or more recipients of Covered
+     Software. However, You may do so only on Your own behalf, and not on behalf
+     of any Contributor. You must make it absolutely clear that any such
+     warranty, support, indemnity, or liability obligation is offered by You
+     alone, and You hereby agree to indemnify every Contributor for any
+     liability incurred by such Contributor as a result of warranty, support,
+     indemnity or liability terms You offer. You may include additional
+     disclaimers of warranty and limitations of liability specific to any
+     jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+
+   If it is impossible for You to comply with any of the terms of this License
+   with respect to some or all of the Covered Software due to statute, judicial
+   order, or regulation then You must: (a) comply with the terms of this License
+   to the maximum extent possible; and (b) describe the limitations and the code
+   they affect. Such description must be placed in a text file included with all
+   distributions of the Covered Software under this License. Except to the
+   extent prohibited by statute or regulation, such description must be
+   sufficiently detailed for a recipient of ordinary skill to be able to
+   understand it.
+
+5. Termination
+
+5.1. The rights granted under this License will terminate automatically if You
+     fail to comply with any of its terms. However, if You become compliant,
+     then the rights granted under this License from a particular Contributor
+     are reinstated (a) provisionally, unless and until such Contributor
+     explicitly and finally terminates Your grants, and (b) on an ongoing basis,
+     if such Contributor fails to notify You of the non-compliance by some
+     reasonable means prior to 60 days after You have come back into compliance.
+     Moreover, Your grants from a particular Contributor are reinstated on an
+     ongoing basis if such Contributor notifies You of the non-compliance by
+     some reasonable means, this is the first time You have received notice of
+     non-compliance with this License from such Contributor, and You become
+     compliant prior to 30 days after Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+     infringement claim (excluding declaratory judgment actions, counter-claims,
+     and cross-claims) alleging that a Contributor Version directly or
+     indirectly infringes any patent, then the rights granted to You by any and
+     all Contributors for the Covered Software under Section 2.1 of this License
+     shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user
+     license agreements (excluding distributors and resellers) which have been
+     validly granted by You or Your distributors under this License prior to
+     termination shall survive termination.
+
+6. Disclaimer of Warranty
+
+   Covered Software is provided under this License on an “as is” basis, without
+   warranty of any kind, either expressed, implied, or statutory, including,
+   without limitation, warranties that the Covered Software is free of defects,
+   merchantable, fit for a particular purpose or non-infringing. The entire
+   risk as to the quality and performance of the Covered Software is with You.
+   Should any Covered Software prove defective in any respect, You (not any
+   Contributor) assume the cost of any necessary servicing, repair, or
+   correction. This disclaimer of warranty constitutes an essential part of this
+   License. No use of  any Covered Software is authorized under this License
+   except under this disclaimer.
+
+7. Limitation of Liability
+
+   Under no circumstances and under no legal theory, whether tort (including
+   negligence), contract, or otherwise, shall any Contributor, or anyone who
+   distributes Covered Software as permitted above, be liable to You for any
+   direct, indirect, special, incidental, or consequential damages of any
+   character including, without limitation, damages for lost profits, loss of
+   goodwill, work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses, even if such party shall have been
+   informed of the possibility of such damages. This limitation of liability
+   shall not apply to liability for death or personal injury resulting from such
+   party’s negligence to the extent applicable law prohibits such limitation.
+   Some jurisdictions do not allow the exclusion or limitation of incidental or
+   consequential damages, so this exclusion and limitation may not apply to You.
+
+8. Litigation
+
+   Any litigation relating to this License may be brought only in the courts of
+   a jurisdiction where the defendant maintains its principal place of business
+   and such litigation shall be governed by laws of that jurisdiction, without
+   reference to its conflict-of-law provisions. Nothing in this Section shall
+   prevent a party’s ability to bring cross-claims or counter-claims.
+
+9. Miscellaneous
+
+   This License represents the complete agreement concerning the subject matter
+   hereof. If any provision of this License is held to be unenforceable, such
+   provision shall be reformed only to the extent necessary to make it
+   enforceable. Any law or regulation which provides that the language of a
+   contract shall be construed against the drafter shall not be used to construe
+   this License against a Contributor.
+
+
+10. Versions of the License
+
+10.1. New Versions
+
+      Mozilla Foundation is the license steward. Except as provided in Section
+      10.3, no one other than the license steward has the right to modify or
+      publish new versions of this License. Each version will be given a
+      distinguishing version number.
+
+10.2. Effect of New Versions
+
+      You may distribute the Covered Software under the terms of the version of
+      the License under which You originally received the Covered Software, or
+      under the terms of any subsequent version published by the license
+      steward.
+
+10.3. Modified Versions
+
+      If you create software not governed by this License, and you want to
+      create a new license for such software, you may create and use a modified
+      version of this License if you rename the license and remove any
+      references to the name of the license steward (except to note that such
+      modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses
+      If You choose to distribute Source Code Form that is Incompatible With
+      Secondary Licenses under the terms of this version of the License, the
+      notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+
+      This Source Code Form is subject to the
+      terms of the Mozilla Public License, v.
+      2.0. If a copy of the MPL was not
+      distributed with this file, You can
+      obtain one at
+      http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular file, then
+You may include the notice in a location (such as a LICENSE file in a relevant
+directory) where a recipient would be likely to look for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - “Incompatible With Secondary Licenses” Notice
+
+      This Source Code Form is “Incompatible
+      With Secondary Licenses”, as defined by
+      the Mozilla Public License, v. 2.0.
diff --git a/vendor/github.com/hashicorp/go-multierror/Makefile b/vendor/github.com/hashicorp/go-multierror/Makefile
new file mode 100644
index 00000000..b97cd6ed
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/Makefile
@@ -0,0 +1,31 @@
+TEST?=./...
+
+default: test
+
+# test runs the test suite and vets the code.
+test: generate
+	@echo "==> Running tests..."
+	@go list $(TEST) \
+		| grep -v "/vendor/" \
+		| xargs -n1 go test -timeout=60s -parallel=10 ${TESTARGS}
+
+# testrace runs the race checker
+testrace: generate
+	@echo "==> Running tests (race)..."
+	@go list $(TEST) \
+		| grep -v "/vendor/" \
+		| xargs -n1 go test -timeout=60s -race ${TESTARGS}
+
+# updatedeps installs all the dependencies needed to run and build.
+updatedeps:
+	@sh -c "'${CURDIR}/scripts/deps.sh' '${NAME}'"
+
+# generate runs `go generate` to build the dynamically generated source files.
+generate:
+	@echo "==> Generating..."
+	@find . -type f -name '.DS_Store' -delete
+	@go list ./... \
+		| grep -v "/vendor/" \
+		| xargs -n1 go generate
+
+.PHONY: default test testrace updatedeps generate
diff --git a/vendor/github.com/hashicorp/go-multierror/README.md b/vendor/github.com/hashicorp/go-multierror/README.md
new file mode 100644
index 00000000..ead5830f
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/README.md
@@ -0,0 +1,97 @@
+# go-multierror
+
+[![Build Status](http://img.shields.io/travis/hashicorp/go-multierror.svg?style=flat-square)][travis]
+[![Go Documentation](http://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)][godocs]
+
+[travis]: https://travis-ci.org/hashicorp/go-multierror
+[godocs]: https://godoc.org/github.com/hashicorp/go-multierror
+
+`go-multierror` is a package for Go that provides a mechanism for
+representing a list of `error` values as a single `error`.
+
+This allows a function in Go to return an `error` that might actually
+be a list of errors. If the caller knows this, they can unwrap the
+list and access the errors. If the caller doesn't know, the error
+formats to a nice human-readable format.
+
+`go-multierror` implements the
+[errwrap](https://github.com/hashicorp/errwrap) interface so that it can
+be used with that library, as well.
+
+## Installation and Docs
+
+Install using `go get github.com/hashicorp/go-multierror`.
+
+Full documentation is available at
+http://godoc.org/github.com/hashicorp/go-multierror
+
+## Usage
+
+go-multierror is easy to use and purposely built to be unobtrusive in
+existing Go applications/libraries that may not be aware of it.
+
+**Building a list of errors**
+
+The `Append` function is used to create a list of errors. This function
+behaves a lot like the Go built-in `append` function: it doesn't matter
+if the first argument is nil, a `multierror.Error`, or any other `error`,
+the function behaves as you would expect.
+
+```go
+var result error
+
+if err := step1(); err != nil {
+	result = multierror.Append(result, err)
+}
+if err := step2(); err != nil {
+	result = multierror.Append(result, err)
+}
+
+return result
+```
+
+**Customizing the formatting of the errors**
+
+By specifying a custom `ErrorFormat`, you can customize the format
+of the `Error() string` function:
+
+```go
+var result *multierror.Error
+
+// ... accumulate errors here, maybe using Append
+
+if result != nil {
+	result.ErrorFormat = func([]error) string {
+		return "errors!"
+	}
+}
+```
+
+**Accessing the list of errors**
+
+`multierror.Error` implements `error` so if the caller doesn't know about
+multierror, it will work just fine. But if you're aware a multierror might
+be returned, you can use type switches to access the list of errors:
+
+```go
+if err := something(); err != nil {
+	if merr, ok := err.(*multierror.Error); ok {
+		// Use merr.Errors
+	}
+}
+```
+
+**Returning a multierror only if there are errors**
+
+If you build a `multierror.Error`, you can use the `ErrorOrNil` function
+to return an `error` implementation only if there are errors to return:
+
+```go
+var result *multierror.Error
+
+// ... accumulate errors here
+
+// Return the `error` only if errors were added to the multierror, otherwise
+// return nil since there are no errors.
+return result.ErrorOrNil()
+```
diff --git a/vendor/github.com/hashicorp/go-multierror/append.go b/vendor/github.com/hashicorp/go-multierror/append.go
new file mode 100644
index 00000000..775b6e75
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/append.go
@@ -0,0 +1,41 @@
+package multierror
+
+// Append is a helper function that will append more errors
+// onto an Error in order to create a larger multi-error.
+//
+// If err is not a multierror.Error, then it will be turned into
+// one. If any of the errs are multierr.Error, they will be flattened
+// one level into err.
+func Append(err error, errs ...error) *Error {
+	switch err := err.(type) {
+	case *Error:
+		// Typed nils can reach here, so initialize if we are nil
+		if err == nil {
+			err = new(Error)
+		}
+
+		// Go through each error and flatten
+		for _, e := range errs {
+			switch e := e.(type) {
+			case *Error:
+				if e != nil {
+					err.Errors = append(err.Errors, e.Errors...)
+				}
+			default:
+				if e != nil {
+					err.Errors = append(err.Errors, e)
+				}
+			}
+		}
+
+		return err
+	default:
+		newErrs := make([]error, 0, len(errs)+1)
+		if err != nil {
+			newErrs = append(newErrs, err)
+		}
+		newErrs = append(newErrs, errs...)
+
+		return Append(&Error{}, newErrs...)
+	}
+}
diff --git a/vendor/github.com/hashicorp/go-multierror/flatten.go b/vendor/github.com/hashicorp/go-multierror/flatten.go
new file mode 100644
index 00000000..aab8e9ab
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/flatten.go
@@ -0,0 +1,26 @@
+package multierror
+
+// Flatten flattens the given error, merging any *Errors together into
+// a single *Error.
+func Flatten(err error) error {
+	// If it isn't an *Error, just return the error as-is
+	if _, ok := err.(*Error); !ok {
+		return err
+	}
+
+	// Otherwise, make the result and flatten away!
+	flatErr := new(Error)
+	flatten(err, flatErr)
+	return flatErr
+}
+
+func flatten(err error, flatErr *Error) {
+	switch err := err.(type) {
+	case *Error:
+		for _, e := range err.Errors {
+			flatten(e, flatErr)
+		}
+	default:
+		flatErr.Errors = append(flatErr.Errors, err)
+	}
+}
diff --git a/vendor/github.com/hashicorp/go-multierror/format.go b/vendor/github.com/hashicorp/go-multierror/format.go
new file mode 100644
index 00000000..47f13c49
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/format.go
@@ -0,0 +1,27 @@
+package multierror
+
+import (
+	"fmt"
+	"strings"
+)
+
+// ErrorFormatFunc is a function callback that is called by Error to
+// turn the list of errors into a string.
+type ErrorFormatFunc func([]error) string
+
+// ListFormatFunc is a basic formatter that outputs the number of errors
+// that occurred along with a bullet point list of the errors.
+func ListFormatFunc(es []error) string {
+	if len(es) == 1 {
+		return fmt.Sprintf("1 error occurred:\n\t* %s\n\n", es[0])
+	}
+
+	points := make([]string, len(es))
+	for i, err := range es {
+		points[i] = fmt.Sprintf("* %s", err)
+	}
+
+	return fmt.Sprintf(
+		"%d errors occurred:\n\t%s\n\n",
+		len(es), strings.Join(points, "\n\t"))
+}
diff --git a/vendor/github.com/hashicorp/go-multierror/go.mod b/vendor/github.com/hashicorp/go-multierror/go.mod
new file mode 100644
index 00000000..2534331d
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/go.mod
@@ -0,0 +1,3 @@
+module github.com/hashicorp/go-multierror
+
+require github.com/hashicorp/errwrap v1.0.0
diff --git a/vendor/github.com/hashicorp/go-multierror/go.sum b/vendor/github.com/hashicorp/go-multierror/go.sum
new file mode 100644
index 00000000..85b1f8ff
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/go.sum
@@ -0,0 +1,4 @@
+github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce h1:prjrVgOk2Yg6w+PflHoszQNLTUh4kaByUcEWM/9uin4=
+github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
diff --git a/vendor/github.com/hashicorp/go-multierror/multierror.go b/vendor/github.com/hashicorp/go-multierror/multierror.go
new file mode 100644
index 00000000..89b1422d
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/multierror.go
@@ -0,0 +1,51 @@
+package multierror
+
+import (
+	"fmt"
+)
+
+// Error is an error type to track multiple errors. This is used to
+// accumulate errors in cases and return them as a single "error".
+type Error struct {
+	Errors      []error
+	ErrorFormat ErrorFormatFunc
+}
+
+func (e *Error) Error() string {
+	fn := e.ErrorFormat
+	if fn == nil {
+		fn = ListFormatFunc
+	}
+
+	return fn(e.Errors)
+}
+
+// ErrorOrNil returns an error interface if this Error represents
+// a list of errors, or returns nil if the list of errors is empty. This
+// function is useful at the end of accumulation to make sure that the value
+// returned represents the existence of errors.
+func (e *Error) ErrorOrNil() error {
+	if e == nil {
+		return nil
+	}
+	if len(e.Errors) == 0 {
+		return nil
+	}
+
+	return e
+}
+
+func (e *Error) GoString() string {
+	return fmt.Sprintf("*%#v", *e)
+}
+
+// WrappedErrors returns the list of errors that this Error is wrapping.
+// It is an implementation of the errwrap.Wrapper interface so that
+// multierror.Error can be used with that library.
+//
+// This method is not safe to be called concurrently and is no different
+// than accessing the Errors field directly. It is implemented only to
+// satisfy the errwrap.Wrapper interface.
+func (e *Error) WrappedErrors() []error {
+	return e.Errors
+}
diff --git a/vendor/github.com/hashicorp/go-multierror/prefix.go b/vendor/github.com/hashicorp/go-multierror/prefix.go
new file mode 100644
index 00000000..5c477abe
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/prefix.go
@@ -0,0 +1,37 @@
+package multierror
+
+import (
+	"fmt"
+
+	"github.com/hashicorp/errwrap"
+)
+
+// Prefix is a helper function that will prefix some text
+// to the given error. If the error is a multierror.Error, then
+// it will be prefixed to each wrapped error.
+//
+// This is useful to use when appending multiple multierrors
+// together in order to give better scoping.
+func Prefix(err error, prefix string) error {
+	if err == nil {
+		return nil
+	}
+
+	format := fmt.Sprintf("%s {{err}}", prefix)
+	switch err := err.(type) {
+	case *Error:
+		// Typed nils can reach here, so initialize if we are nil
+		if err == nil {
+			err = new(Error)
+		}
+
+		// Wrap each of the errors
+		for i, e := range err.Errors {
+			err.Errors[i] = errwrap.Wrapf(format, e)
+		}
+
+		return err
+	default:
+		return errwrap.Wrapf(format, err)
+	}
+}
diff --git a/vendor/github.com/hashicorp/go-multierror/sort.go b/vendor/github.com/hashicorp/go-multierror/sort.go
new file mode 100644
index 00000000..fecb14e8
--- /dev/null
+++ b/vendor/github.com/hashicorp/go-multierror/sort.go
@@ -0,0 +1,16 @@
+package multierror
+
+// Len implements sort.Interface function for length
+func (err Error) Len() int {
+	return len(err.Errors)
+}
+
+// Swap implements sort.Interface function for swapping elements
+func (err Error) Swap(i, j int) {
+	err.Errors[i], err.Errors[j] = err.Errors[j], err.Errors[i]
+}
+
+// Less implements sort.Interface function for determining order
+func (err Error) Less(i, j int) bool {
+	return err.Errors[i].Error() < err.Errors[j].Error()
+}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 30425553..66ce2b69 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -120,8 +120,12 @@ github.com/gorilla/websocket
 # github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79
 github.com/gregjones/httpcache
 github.com/gregjones/httpcache/diskcache
+# github.com/hashicorp/errwrap v1.0.0
+github.com/hashicorp/errwrap
 # github.com/hashicorp/go-hclog v0.14.1
 github.com/hashicorp/go-hclog
+# github.com/hashicorp/go-multierror v1.0.0
+github.com/hashicorp/go-multierror
 # github.com/hashicorp/go-plugin v0.0.0-20190220160451-3f118e8ee104
 github.com/hashicorp/go-plugin
 github.com/hashicorp/go-plugin/internal/plugin
diff --git a/web/.storybook/preview.js b/web/.storybook/preview.js
index 848844a1..4edba726 100644
--- a/web/.storybook/preview.js
+++ b/web/.storybook/preview.js
@@ -1,17 +1,18 @@
-import {addDecorator, moduleMetadata} from '@storybook/angular';
+import { addDecorator, moduleMetadata } from '@storybook/angular';
 import { SharedModule } from '../src/app/modules/shared/shared.module';
 import { MarkdownModule, MarkedOptions } from 'ngx-markdown';
 import { setConsoleOptions } from '@storybook/addon-console';
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
-import {APP_INITIALIZER} from "@angular/core";
-import {InitService} from "../src/app/modules/shared/services/init/init.service";
-import {RouterModule} from "@angular/router";
-import {HttpClientModule} from "@angular/common/http";
-import {BrowserModule} from "@angular/platform-browser";
-import {AppRoutingModule} from "../src/app/app-routing.module";
+import { APP_INITIALIZER } from "@angular/core";
+import { InitService } from "../src/app/modules/shared/services/init/init.service";
+import { RouterModule } from "@angular/router";
+import { HttpClientModule } from "@angular/common/http";
+import { BrowserModule } from "@angular/platform-browser";
+import { AppRoutingModule } from "../src/app/app-routing.module";
 import { setCompodocJson } from '@storybook/addon-docs/angular';
 import docJson from '../documentation.json';
 import { themes } from '@storybook/theming';
+import { MonacoEditorModule } from 'ng-monaco-editor';
 
 setCompodocJson(docJson);
 
@@ -43,6 +44,10 @@ addDecorator(
           },
         },
       }),
+      MonacoEditorModule.forRoot({
+        baseUrl: 'lib',
+        defaultOptions: {},
+      }),
       RouterModule,
       SharedModule,
     ],
diff --git a/web/package-lock.json b/web/package-lock.json
index af14c8ae..747ff641 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -10491,9 +10491,9 @@
       "dev": true
     },
     "base64id": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz",
-      "integrity": "sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
+      "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
       "dev": true
     },
     "basic-auth": {
@@ -12811,9 +12811,9 @@
       "dev": true
     },
     "cytoscape": {
-      "version": "3.15.1",
-      "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.15.1.tgz",
-      "integrity": "sha512-xU/0KYoCErDXseKiPYrJJRSwwxbaDSo2rNpKCd7uNPw7ZDrMahCo+fE0agVvw0ce+6gAg5bDnC1pT8AgmaqqtA==",
+      "version": "3.15.2",
+      "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.15.2.tgz",
+      "integrity": "sha512-HCAWjKYH60wMAl4x/QwInVuqQapJn/y6U98T0hl1WAiaNasvgIO5i+oUzddf/exG1oVKfXSNQz6E5Em6c2+HFA==",
       "requires": {
         "heap": "^0.2.6",
         "lodash.debounce": "^4.0.8"
@@ -13172,9 +13172,9 @@
       }
     },
     "date-format": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz",
-      "integrity": "sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==",
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/date-format/-/date-format-3.0.0.tgz",
+      "integrity": "sha512-eyTcpKOcamdhWJXj56DpQMo1ylSQpcGtGKXcU0Tb97+K56/CF5amAqqqNj0+KvA0iw2ynxtHWFsPDSClCxe48w==",
       "dev": true
     },
     "dayjs": {
@@ -14278,17 +14278,17 @@
       }
     },
     "engine.io": {
-      "version": "3.2.1",
-      "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.2.1.tgz",
-      "integrity": "sha512-+VlKzHzMhaU+GsCIg4AoXF1UdDFjHHwMmMKqMJNDNLlUlejz58FCy4LBqB2YVJskHGYl06BatYWKP2TVdVXE5w==",
+      "version": "3.4.2",
+      "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.4.2.tgz",
+      "integrity": "sha512-b4Q85dFkGw+TqgytGPrGgACRUhsdKc9S9ErRAXpPGy/CXKs4tYoHDkvIRdsseAF7NjfVwjRFIn6KTnbw7LwJZg==",
       "dev": true,
       "requires": {
         "accepts": "~1.3.4",
-        "base64id": "1.0.0",
+        "base64id": "2.0.0",
         "cookie": "0.3.1",
-        "debug": "~3.1.0",
-        "engine.io-parser": "~2.1.0",
-        "ws": "~3.3.1"
+        "debug": "~4.1.0",
+        "engine.io-parser": "~2.2.0",
+        "ws": "^7.1.2"
       },
       "dependencies": {
         "cookie": {
@@ -14296,92 +14296,43 @@
           "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
           "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=",
           "dev": true
-        },
-        "debug": {
-          "version": "3.1.0",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
-          "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
-          "dev": true,
-          "requires": {
-            "ms": "2.0.0"
-          }
-        },
-        "ms": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
-          "dev": true
-        },
-        "ws": {
-          "version": "3.3.3",
-          "resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz",
-          "integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==",
-          "dev": true,
-          "requires": {
-            "async-limiter": "~1.0.0",
-            "safe-buffer": "~5.1.0",
-            "ultron": "~1.1.0"
-          }
         }
       }
     },
     "engine.io-client": {
-      "version": "3.2.1",
-      "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.2.1.tgz",
-      "integrity": "sha512-y5AbkytWeM4jQr7m/koQLc5AxpRKC1hEVUb/s1FUAWEJq5AzJJ4NLvzuKPuxtDi5Mq755WuDvZ6Iv2rXj4PTzw==",
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.4.3.tgz",
+      "integrity": "sha512-0NGY+9hioejTEJCaSJZfWZLk4FPI9dN+1H1C4+wj2iuFba47UgZbJzfWs4aNFajnX/qAaYKbe2lLTfEEWzCmcw==",
       "dev": true,
       "requires": {
-        "component-emitter": "1.2.1",
+        "component-emitter": "~1.3.0",
         "component-inherit": "0.0.3",
-        "debug": "~3.1.0",
-        "engine.io-parser": "~2.1.1",
+        "debug": "~4.1.0",
+        "engine.io-parser": "~2.2.0",
         "has-cors": "1.1.0",
         "indexof": "0.0.1",
         "parseqs": "0.0.5",
         "parseuri": "0.0.5",
-        "ws": "~3.3.1",
+        "ws": "~6.1.0",
         "xmlhttprequest-ssl": "~1.5.4",
         "yeast": "0.1.2"
       },
       "dependencies": {
-        "component-emitter": {
-          "version": "1.2.1",
-          "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
-          "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=",
-          "dev": true
-        },
-        "debug": {
-          "version": "3.1.0",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
-          "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
-          "dev": true,
-          "requires": {
-            "ms": "2.0.0"
-          }
-        },
-        "ms": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
-          "dev": true
-        },
         "ws": {
-          "version": "3.3.3",
-          "resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz",
-          "integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==",
+          "version": "6.1.4",
+          "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.4.tgz",
+          "integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==",
           "dev": true,
           "requires": {
-            "async-limiter": "~1.0.0",
-            "safe-buffer": "~5.1.0",
-            "ultron": "~1.1.0"
+            "async-limiter": "~1.0.0"
           }
         }
       }
     },
     "engine.io-parser": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.1.3.tgz",
-      "integrity": "sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==",
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.2.0.tgz",
+      "integrity": "sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==",
       "dev": true,
       "requires": {
         "after": "0.8.2",
@@ -17488,9 +17439,9 @@
       "dev": true
     },
     "isbinaryfile": {
-      "version": "4.0.5",
-      "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.5.tgz",
-      "integrity": "sha512-Jvz0gpTh1AILHMCBUyqq7xv1ZOQrxTDwyp1/QUq1xFpOBvp4AH5uEobPePJht8KnBGqQIH7We6OR73mXsjG0cA==",
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.6.tgz",
+      "integrity": "sha512-ORrEy+SNVqUhrCaal4hA4fBzhggQQ+BaLntyPOdoEiwlKZW9BZiJXjg3RMiruE4tPEI3pyVPpySHQF/dKWperg==",
       "dev": true
     },
     "isexe": {
@@ -18019,33 +17970,33 @@
       }
     },
     "karma": {
-      "version": "5.0.2",
-      "resolved": "https://registry.npmjs.org/karma/-/karma-5.0.2.tgz",
-      "integrity": "sha512-RpUuCuGJfN3WnjYPGIH+VBF8023Lfm3TQH6D1kcNL+FxtEPc2UUz/nVjbVAGXH4Pm+Q7FVOAQjdAeFUpXpQ3IA==",
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/karma/-/karma-5.1.1.tgz",
+      "integrity": "sha512-xAlOr5PMqUbiKXSv5PCniHWV3aiwj6wIZ0gUVcwpTCPVQm/qH2WAMFWxtnpM6KJqhkRWrIpovR4Rb0rn8GtJzQ==",
       "dev": true,
       "requires": {
-        "body-parser": "^1.16.1",
+        "body-parser": "^1.19.0",
         "braces": "^3.0.2",
         "chokidar": "^3.0.0",
-        "colors": "^1.1.0",
-        "connect": "^3.6.0",
+        "colors": "^1.4.0",
+        "connect": "^3.7.0",
         "di": "^0.0.1",
-        "dom-serialize": "^2.2.0",
-        "flatted": "^2.0.0",
-        "glob": "^7.1.1",
-        "graceful-fs": "^4.1.2",
-        "http-proxy": "^1.13.0",
-        "isbinaryfile": "^4.0.2",
-        "lodash": "^4.17.14",
-        "log4js": "^4.0.0",
-        "mime": "^2.3.1",
-        "minimatch": "^3.0.2",
-        "qjobs": "^1.1.4",
-        "range-parser": "^1.2.0",
-        "rimraf": "^2.6.0",
-        "socket.io": "2.1.1",
+        "dom-serialize": "^2.2.1",
+        "flatted": "^2.0.2",
+        "glob": "^7.1.6",
+        "graceful-fs": "^4.2.4",
+        "http-proxy": "^1.18.1",
+        "isbinaryfile": "^4.0.6",
+        "lodash": "^4.17.15",
+        "log4js": "^6.2.1",
+        "mime": "^2.4.5",
+        "minimatch": "^3.0.4",
+        "qjobs": "^1.2.0",
+        "range-parser": "^1.2.1",
+        "rimraf": "^3.0.2",
+        "socket.io": "^2.3.0",
         "source-map": "^0.6.1",
-        "tmp": "0.0.33",
+        "tmp": "0.2.1",
         "ua-parser-js": "0.7.21",
         "yargs": "^15.3.1"
       },
@@ -18092,6 +18043,12 @@
           "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
           "dev": true
         },
+        "colors": {
+          "version": "1.4.0",
+          "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
+          "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
+          "dev": true
+        },
         "find-up": {
           "version": "4.1.0",
           "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -18108,6 +18065,26 @@
           "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
           "dev": true
         },
+        "glob": {
+          "version": "7.1.6",
+          "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+          "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
+          "dev": true,
+          "requires": {
+            "fs.realpath": "^1.0.0",
+            "inflight": "^1.0.4",
+            "inherits": "2",
+            "minimatch": "^3.0.4",
+            "once": "^1.3.0",
+            "path-is-absolute": "^1.0.0"
+          }
+        },
+        "graceful-fs": {
+          "version": "4.2.4",
+          "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
+          "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==",
+          "dev": true
+        },
         "is-fullwidth-code-point": {
           "version": "3.0.0",
           "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -18124,9 +18101,9 @@
           }
         },
         "mime": {
-          "version": "2.4.4",
-          "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz",
-          "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==",
+          "version": "2.4.6",
+          "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz",
+          "integrity": "sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA==",
           "dev": true
         },
         "p-locate": {
@@ -18150,6 +18127,15 @@
           "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
           "dev": true
         },
+        "rimraf": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+          "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+          "dev": true,
+          "requires": {
+            "glob": "^7.1.3"
+          }
+        },
         "source-map": {
           "version": "0.6.1",
           "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -18176,6 +18162,15 @@
             "ansi-regex": "^5.0.0"
           }
         },
+        "tmp": {
+          "version": "0.2.1",
+          "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
+          "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
+          "dev": true,
+          "requires": {
+            "rimraf": "^3.0.0"
+          }
+        },
         "wrap-ansi": {
           "version": "6.2.0",
           "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
@@ -18188,9 +18183,9 @@
           }
         },
         "yargs": {
-          "version": "15.3.1",
-          "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.3.1.tgz",
-          "integrity": "sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==",
+          "version": "15.4.1",
+          "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+          "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
           "dev": true,
           "requires": {
             "cliui": "^6.0.0",
@@ -18203,7 +18198,7 @@
             "string-width": "^4.2.0",
             "which-module": "^2.0.0",
             "y18n": "^4.0.0",
-            "yargs-parser": "^18.1.1"
+            "yargs-parser": "^18.1.2"
           }
         },
         "yargs-parser": {
@@ -18813,16 +18808,16 @@
       }
     },
     "log4js": {
-      "version": "4.5.1",
-      "resolved": "https://registry.npmjs.org/log4js/-/log4js-4.5.1.tgz",
-      "integrity": "sha512-EEEgFcE9bLgaYUKuozyFfytQM2wDHtXn4tAN41pkaxpNjAykv11GVdeI4tHtmPWW4Xrgh9R/2d7XYghDVjbKKw==",
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.3.0.tgz",
+      "integrity": "sha512-Mc8jNuSFImQUIateBFwdOQcmC6Q5maU0VVvdC2R6XMb66/VnT+7WS4D/0EeNMZu1YODmJe5NIn2XftCzEocUgw==",
       "dev": true,
       "requires": {
-        "date-format": "^2.0.0",
+        "date-format": "^3.0.0",
         "debug": "^4.1.1",
-        "flatted": "^2.0.0",
+        "flatted": "^2.0.1",
         "rfdc": "^1.1.4",
-        "streamroller": "^1.0.6"
+        "streamroller": "^2.2.4"
       }
     },
     "loglevel": {
@@ -24638,34 +24633,17 @@
       }
     },
     "socket.io": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.1.1.tgz",
-      "integrity": "sha512-rORqq9c+7W0DAK3cleWNSyfv/qKXV99hV4tZe+gGLfBECw3XEhBy7x85F3wypA9688LKjtwO9pX9L33/xQI8yA==",
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.3.0.tgz",
+      "integrity": "sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==",
       "dev": true,
       "requires": {
-        "debug": "~3.1.0",
-        "engine.io": "~3.2.0",
+        "debug": "~4.1.0",
+        "engine.io": "~3.4.0",
         "has-binary2": "~1.0.2",
         "socket.io-adapter": "~1.1.0",
-        "socket.io-client": "2.1.1",
-        "socket.io-parser": "~3.2.0"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "3.1.0",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
-          "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
-          "dev": true,
-          "requires": {
-            "ms": "2.0.0"
-          }
-        },
-        "ms": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
-          "dev": true
-        }
+        "socket.io-client": "2.3.0",
+        "socket.io-parser": "~3.4.0"
       }
     },
     "socket.io-adapter": {
@@ -24675,24 +24653,24 @@
       "dev": true
     },
     "socket.io-client": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.1.1.tgz",
-      "integrity": "sha512-jxnFyhAuFxYfjqIgduQlhzqTcOEQSn+OHKVfAxWaNWa7ecP7xSNk2Dx/3UEsDcY7NcFafxvNvKPmmO7HTwTxGQ==",
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.3.0.tgz",
+      "integrity": "sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==",
       "dev": true,
       "requires": {
         "backo2": "1.0.2",
         "base64-arraybuffer": "0.1.5",
         "component-bind": "1.0.0",
         "component-emitter": "1.2.1",
-        "debug": "~3.1.0",
-        "engine.io-client": "~3.2.0",
+        "debug": "~4.1.0",
+        "engine.io-client": "~3.4.0",
         "has-binary2": "~1.0.2",
         "has-cors": "1.1.0",
         "indexof": "0.0.1",
         "object-component": "0.0.3",
         "parseqs": "0.0.5",
         "parseuri": "0.0.5",
-        "socket.io-parser": "~3.2.0",
+        "socket.io-parser": "~3.3.0",
         "to-array": "0.1.4"
       },
       "dependencies": {
@@ -24702,31 +24680,50 @@
           "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=",
           "dev": true
         },
-        "debug": {
-          "version": "3.1.0",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
-          "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
-          "dev": true,
-          "requires": {
-            "ms": "2.0.0"
-          }
+        "isarray": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
+          "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=",
+          "dev": true
         },
         "ms": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
           "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
           "dev": true
+        },
+        "socket.io-parser": {
+          "version": "3.3.0",
+          "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.0.tgz",
+          "integrity": "sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==",
+          "dev": true,
+          "requires": {
+            "component-emitter": "1.2.1",
+            "debug": "~3.1.0",
+            "isarray": "2.0.1"
+          },
+          "dependencies": {
+            "debug": {
+              "version": "3.1.0",
+              "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+              "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+              "dev": true,
+              "requires": {
+                "ms": "2.0.0"
+              }
+            }
+          }
         }
       }
     },
     "socket.io-parser": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.2.0.tgz",
-      "integrity": "sha512-FYiBx7rc/KORMJlgsXysflWx/RIvtqZbyGLlHZvjfmPTPeuD/I8MaW7cfFrj5tRltICJdgwflhfZ3NVVbVLFQA==",
+      "version": "3.4.1",
+      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.4.1.tgz",
+      "integrity": "sha512-11hMgzL+WCLWf1uFtHSNvliI++tcRUWdoeYuwIl+Axvwy9z2gQM+7nJyN3STj1tLj5JyIUH8/gpDGxzAlDdi0A==",
       "dev": true,
       "requires": {
         "component-emitter": "1.2.1",
-        "debug": "~3.1.0",
+        "debug": "~4.1.0",
         "isarray": "2.0.1"
       },
       "dependencies": {
@@ -24736,26 +24733,11 @@
           "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=",
           "dev": true
         },
-        "debug": {
-          "version": "3.1.0",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
-          "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
-          "dev": true,
-          "requires": {
-            "ms": "2.0.0"
-          }
-        },
         "isarray": {
           "version": "2.0.1",
           "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
           "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=",
           "dev": true
-        },
-        "ms": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
-          "dev": true
         }
       }
     },
@@ -25248,34 +25230,29 @@
       "dev": true
     },
     "streamroller": {
-      "version": "1.0.6",
-      "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-1.0.6.tgz",
-      "integrity": "sha512-3QC47Mhv3/aZNFpDDVO44qQb9gwB9QggMEE0sQmkTAwBVYdBRWISdsywlkfm5II1Q5y/pmrHflti/IgmIzdDBg==",
+      "version": "2.2.4",
+      "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-2.2.4.tgz",
+      "integrity": "sha512-OG79qm3AujAM9ImoqgWEY1xG4HX+Lw+yY6qZj9R1K2mhF5bEmQ849wvrb+4vt4jLMLzwXttJlQbOdPOQVRv7DQ==",
       "dev": true,
       "requires": {
-        "async": "^2.6.2",
-        "date-format": "^2.0.0",
-        "debug": "^3.2.6",
-        "fs-extra": "^7.0.1",
-        "lodash": "^4.17.14"
+        "date-format": "^2.1.0",
+        "debug": "^4.1.1",
+        "fs-extra": "^8.1.0"
       },
       "dependencies": {
-        "debug": {
-          "version": "3.2.6",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
-          "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
-          "dev": true,
-          "requires": {
-            "ms": "^2.1.1"
-          }
+        "date-format": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz",
+          "integrity": "sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==",
+          "dev": true
         },
         "fs-extra": {
-          "version": "7.0.1",
-          "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
-          "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
+          "version": "8.1.0",
+          "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
+          "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
           "dev": true,
           "requires": {
-            "graceful-fs": "^4.1.2",
+            "graceful-fs": "^4.2.0",
             "jsonfile": "^4.0.0",
             "universalify": "^0.1.0"
           }
@@ -26491,12 +26468,6 @@
       "dev": true,
       "optional": true
     },
-    "ultron": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz",
-      "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==",
-      "dev": true
-    },
     "unc-path-regex": {
       "version": "0.1.2",
       "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",
@@ -29048,9 +29019,9 @@
       "dev": true
     },
     "xterm": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.4.0.tgz",
-      "integrity": "sha512-JGIpigWM3EBWvnS3rtBuefkiToIILSK1HYMXy4BCsUpO+O4UeeV+/U1AdAXgCB6qJrnPNb7yLgBsVCQUNMteig=="
+      "version": "4.8.1",
+      "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.8.1.tgz",
+      "integrity": "sha512-ax91ny4tI5eklqIfH79OUSGE2PUX2rGbwONmB6DfqpyhSZO8/cf++sqiaMWEVCMjACyMfnISW7C3gGMoNvNolQ=="
     },
     "xterm-addon-fit": {
       "version": "0.3.0",
diff --git a/web/src/app/components/smart/home/home.component.spec.ts b/web/src/app/components/smart/home/home.component.spec.ts
index 259b668e..5a5950bb 100644
--- a/web/src/app/components/smart/home/home.component.spec.ts
+++ b/web/src/app/components/smart/home/home.component.spec.ts
@@ -42,7 +42,7 @@ describe('HomeComponent', () => {
   });
 
   it('should load light theme when component mounts', () => {
-    spyOn(themeService, 'loadTheme');
+    spyOn(themeService, 'loadTheme').and.returnValue(Promise.resolve());
     component.ngOnInit();
 
     expect(themeService.isLightThemeEnabled()).toBeTruthy();
diff --git a/web/src/app/components/smart/home/home.component.ts b/web/src/app/components/smart/home/home.component.ts
index c7d8b36a..969d1e98 100644
--- a/web/src/app/components/smart/home/home.component.ts
+++ b/web/src/app/components/smart/home/home.component.ts
@@ -15,7 +15,9 @@ export class HomeComponent implements OnInit {
   ) {}
 
   ngOnInit() {
-    this.themeService.loadTheme();
+    this.themeService.loadTheme().catch(e => {
+      console.error('Unable to load theme:', e);
+    });
 
     if (this.electronService.isElectron()) {
       this.renderer.addClass(document.body, 'electron');
diff --git a/web/src/app/modules/shared/services/init/init.service.ts b/web/src/app/modules/shared/services/init/init.service.ts
index 09514fd4..c8f05ff3 100644
--- a/web/src/app/modules/shared/services/init/init.service.ts
+++ b/web/src/app/modules/shared/services/init/init.service.ts
@@ -5,7 +5,9 @@ import { ThemeService } from '../../../sugarloaf/components/smart/theme-switch/t
 export class InitService {
   constructor(private themeService: ThemeService) {}
 
-  init(): Promise<any> {
-    return this.themeService.loadTheme();
+  init(): void {
+    this.themeService.loadTheme().catch(e => {
+      console.error('Unable to load theme:', e);
+    });
   }
 }
diff --git a/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.spec.ts b/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.spec.ts
index 18b2b992..29ee584a 100644
--- a/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.spec.ts
+++ b/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.spec.ts
@@ -11,6 +11,7 @@ import { MonacoEditorConfig, MonacoProviderService } from 'ng-monaco-editor';
 describe('ThemeSwitchButtonComponent', () => {
   let component: ThemeSwitchButtonComponent;
   let fixture: ComponentFixture<ThemeSwitchButtonComponent>;
+  let monacoService: MonacoProviderService;
 
   beforeEach(async(() => {
     TestBed.configureTestingModule({
@@ -24,6 +25,8 @@ describe('ThemeSwitchButtonComponent', () => {
   }));
 
   beforeEach(() => {
+    monacoService = TestBed.inject(MonacoProviderService);
+
     fixture = TestBed.createComponent(ThemeSwitchButtonComponent);
     component = fixture.componentInstance;
   });
@@ -32,13 +35,15 @@ describe('ThemeSwitchButtonComponent', () => {
     expect(component).toBeTruthy();
   });
 
-  it('should switch theme', () => {
-    component.switchTheme();
+  it('should switch theme', async () => {
+    spyOn(monacoService, 'initMonaco').and.returnValue(Promise.resolve());
+
+    await component.switchTheme();
 
     expect(localStorage.getItem('theme')).toBe('dark');
     expect(component.lightThemeEnabled).toBe(false);
 
-    component.switchTheme();
+    await component.switchTheme();
 
     expect(localStorage.getItem('theme')).toBe('light');
     expect(component.lightThemeEnabled).toBe(true);
diff --git a/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.ts b/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.ts
index ab13d43a..8e2ec737 100644
--- a/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.ts
+++ b/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch-button.component.ts
@@ -21,8 +21,14 @@ export class ThemeSwitchButtonComponent implements OnInit {
     this.lightThemeEnabled = this.themeService.isLightThemeEnabled();
   }
 
-  switchTheme() {
-    this.themeService.switchTheme();
-    this.lightThemeEnabled = this.themeService.isLightThemeEnabled();
+  switchTheme(): Promise<any> {
+    return this.themeService
+      .switchTheme()
+      .then(() => {
+        this.lightThemeEnabled = this.themeService.isLightThemeEnabled();
+      })
+      .catch(e => {
+        console.error('Unable to switch theme:', e);
+      });
   }
 }
diff --git a/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch.service.ts b/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch.service.ts
index 3a7afef6..5dc4a497 100644
--- a/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch.service.ts
+++ b/web/src/app/modules/sugarloaf/components/smart/theme-switch/theme-switch.service.ts
@@ -56,7 +56,9 @@ export class ThemeService implements OnDestroy {
     this.storageEventHandler = (e: StorageEvent): void => {
       if (e.key === 'theme' && e.newValue !== this.themeType) {
         // another window switched the theme
-        this.switchTheme();
+        this.switchTheme().catch(err => {
+          console.error('Unable to switch theme:', err);
+        });
       }
     };
     addEventListener('storage', this.storageEventHandler);
@@ -102,11 +104,11 @@ export class ThemeService implements OnDestroy {
     });
   }
 
-  switchTheme(): void {
+  switchTheme(): Promise<any> {
     this.themeType = this.isLightThemeEnabled() ? 'dark' : 'light';
     localStorage.setItem('theme', this.themeType);
 
-    this.loadTheme();
+    return this.loadTheme();
   }
 
   isLightThemeEnabled(): boolean {
diff --git a/web/src/stories/editor.stories.mdx b/web/src/stories/editor.stories.mdx
index f2be3c2b..a9d26094 100644
--- a/web/src/stories/editor.stories.mdx
+++ b/web/src/stories/editor.stories.mdx
@@ -1,7 +1,6 @@
 import { moduleMetadata } from '@storybook/angular';
 import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs/blocks';
 import { object, withKnobs } from '@storybook/addon-knobs';
-import { MonacoEditorModule } from "ng-monaco-editor";
 import { EditorComponent } from '../app/modules/shared/components/smart/editor/editor.component';
 
 export const editorDocs= { source: { code: `data := "---\nmetadata:\n  creationTimestamp: null\nspec:\n  containers: null\nstatus: {}\n"
@@ -52,14 +51,6 @@ export const editorView = {
     props: {
       view: object('View', editorView),
     },
-    moduleMetadata: {
-      imports: [
-        MonacoEditorModule.forRoot({
-          baseUrl: 'lib',
-          defaultOptions: {},
-        }),
-      ],
-    },
     template: `
       <div class="main-container">
           <div class="content-container">


Test patch:
diff --git a/pkg/plugin/javascript/dashboard_client_test.go b/pkg/plugin/javascript/dashboard_client_test.go
new file mode 100644
index 00000000..3b9ca40b
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_client_test.go
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+	"sort"
+	"testing"
+
+	"github.com/dop251/goja"
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+)
+
+func TestModularDashboardClientFactory_Create(t *testing.T) {
+	type ctorArgs struct {
+		functions func(ctx context.Context, ctrl *gomock.Controller, vm *goja.Runtime) []octant.DashboardClientFunction
+	}
+	tests := []struct {
+		name     string
+		ctorArgs ctorArgs
+		want     []string
+	}{
+		{
+			name: "functions are valid",
+			ctorArgs: ctorArgs{
+				functions: func(ctx context.Context, ctrl *gomock.Controller, vm *goja.Runtime) []octant.DashboardClientFunction {
+					return []octant.DashboardClientFunction{
+						createFn(ctx, ctrl, vm, "Fn2"),
+						createFn(ctx, ctrl, vm, "Fn1"),
+					}
+				},
+			},
+			want: []string{"Fn1", "Fn2"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctrl := gomock.NewController(t)
+			defer ctrl.Finish()
+
+			ctx := context.Background()
+			vm := goja.New()
+
+			f := NewModularDashboardClientFactory(tt.ctorArgs.functions(ctx, ctrl, vm))
+
+			got := f.Create(ctx, vm).Export()
+
+			m, ok := got.(map[string]interface{})
+			if !ok {
+				t.Error("....")
+			}
+
+			var gotFns []string
+			for k := range m {
+				gotFns = append(gotFns, k)
+			}
+			sort.Strings(gotFns)
+			require.Equal(t, tt.want, gotFns)
+		})
+	}
+
+}
diff --git a/pkg/plugin/javascript/dashboard_delete_test.go b/pkg/plugin/javascript/dashboard_delete_test.go
new file mode 100644
index 00000000..3fbb9ea9
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_delete_test.go
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/internal/octant/fake"
+	"github.com/vmware-tanzu/octant/pkg/store"
+	fake2 "github.com/vmware-tanzu/octant/pkg/store/fake"
+)
+
+func TestDashboardDelete_Name(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+	storage := fake.NewMockStorage(ctrl)
+
+	d := NewDashboardDelete(storage)
+
+	want := "Delete"
+	got := d.Name()
+
+	require.Equal(t, want, got)
+}
+
+func TestDashboardDelete_Call(t *testing.T) {
+	type ctorArgs struct {
+		storage func(ctx context.Context, ctrl *gomock.Controller) octant.Storage
+	}
+	tests := []struct {
+		name     string
+		ctorArgs ctorArgs
+		call     string
+		wantErr  bool
+	}{
+		{
+			name: "in general",
+			ctorArgs: ctorArgs{
+				storage: func(ctx context.Context, ctrl *gomock.Controller) octant.Storage {
+					objectStore := fake2.NewMockStore(ctrl)
+					objectStore.EXPECT().
+						Delete(ctx, store.Key{
+							Namespace:  "test",
+							APIVersion: "v1",
+							Kind:       "ReplicaSet",
+							Name:       "my-replica-set"}).
+						Return(nil)
+
+					storage := fake.NewMockStorage(ctrl)
+					storage.EXPECT().ObjectStore().Return(objectStore).AnyTimes()
+
+					return storage
+				},
+			},
+			call: `dashClient.Delete({namespace:'test', apiVersion: 'v1', kind:'ReplicaSet', name: 'my-replica-set'})`,
+		},
+		{
+			name: "delete fails",
+			ctorArgs: ctorArgs{
+				storage: func(ctx context.Context, ctrl *gomock.Controller) octant.Storage {
+					objectStore := fake2.NewMockStore(ctrl)
+					objectStore.EXPECT().
+						Delete(ctx, store.Key{
+							Namespace:  "test",
+							APIVersion: "v1",
+							Kind:       "ReplicaSet",
+							Name:       "my-replica-set"}).
+						Return(errors.New("error"))
+
+					storage := fake.NewMockStorage(ctrl)
+					storage.EXPECT().ObjectStore().Return(objectStore).AnyTimes()
+
+					return storage
+				},
+			},
+			call:    `dashClient.Delete({namespace:'test', apiVersion: 'v1', kind:'ReplicaSet', name: 'my-replica-set'})`,
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctrl := gomock.NewController(t)
+			defer ctrl.Finish()
+
+			ctx := context.Background()
+			d := NewDashboardDelete(tt.ctorArgs.storage(ctx, ctrl))
+
+			runner := functionRunner{wantErr: tt.wantErr}
+			runner.run(ctx, t, d, tt.call)
+
+		})
+	}
+}
diff --git a/pkg/plugin/javascript/dashboard_get_test.go b/pkg/plugin/javascript/dashboard_get_test.go
new file mode 100644
index 00000000..1a24a9d7
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_get_test.go
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/internal/octant/fake"
+	"github.com/vmware-tanzu/octant/internal/testutil"
+	"github.com/vmware-tanzu/octant/pkg/store"
+	fake2 "github.com/vmware-tanzu/octant/pkg/store/fake"
+)
+
+func TestDashboardGet(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+	storage := fake.NewMockStorage(ctrl)
+
+	d := NewDashboardGet(storage)
+
+	want := "Get"
+	got := d.Name()
+
+	require.Equal(t, want, got)
+}
+
+func TestDashboardGet_Call(t *testing.T) {
+	type ctorArgs struct {
+		storage func(ctx context.Context, ctrl *gomock.Controller) octant.Storage
+	}
+	tests := []struct {
+		name     string
+		ctorArgs ctorArgs
+		call     string
+		wantErr  bool
+	}{
+		{
+			name: "in general",
+			ctorArgs: ctorArgs{
+				storage: func(ctx context.Context, ctrl *gomock.Controller) octant.Storage {
+					objectStore := fake2.NewMockStore(ctrl)
+					objectStore.EXPECT().
+						Get(ctx, store.Key{
+							Namespace:  "test",
+							APIVersion: "v1",
+							Kind:       "Pod",
+							Name:       "pod"}).
+						Return(testutil.ToUnstructured(t, testutil.CreatePod("pod")), nil)
+
+					storage := fake.NewMockStorage(ctrl)
+					storage.EXPECT().ObjectStore().Return(objectStore).AnyTimes()
+
+					return storage
+				},
+			},
+			call: `dashClient.Get({namespace:'test', apiVersion: 'v1', kind:'Pod', name: 'pod'})`,
+		},
+		{
+			name: "delete fails",
+			ctorArgs: ctorArgs{
+				storage: func(ctx context.Context, ctrl *gomock.Controller) octant.Storage {
+					objectStore := fake2.NewMockStore(ctrl)
+					objectStore.EXPECT().
+						Get(ctx, store.Key{
+							Namespace:  "test",
+							APIVersion: "v1",
+							Kind:       "Pod",
+							Name:       "pod"}).
+						Return(nil, errors.New("error"))
+
+					storage := fake.NewMockStorage(ctrl)
+					storage.EXPECT().ObjectStore().Return(objectStore).AnyTimes()
+
+					return storage
+				},
+			},
+			call:    `dashClient.Get({namespace:'test', apiVersion: 'v1', kind:'Pod', name: 'pod'})`,
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctrl := gomock.NewController(t)
+			defer ctrl.Finish()
+
+			ctx := context.Background()
+			d := NewDashboardGet(tt.ctorArgs.storage(ctx, ctrl))
+
+			runner := functionRunner{wantErr: tt.wantErr}
+			runner.run(ctx, t, d, tt.call)
+
+		})
+	}
+}
diff --git a/pkg/plugin/javascript/dashboard_list_test.go b/pkg/plugin/javascript/dashboard_list_test.go
new file mode 100644
index 00000000..f8986c07
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_list_test.go
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/internal/octant/fake"
+	"github.com/vmware-tanzu/octant/internal/testutil"
+	"github.com/vmware-tanzu/octant/pkg/store"
+	fake2 "github.com/vmware-tanzu/octant/pkg/store/fake"
+)
+
+func TestDashboardList_Name(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+	storage := fake.NewMockStorage(ctrl)
+
+	d := NewDashboardList(storage)
+
+	want := "List"
+	got := d.Name()
+
+	require.Equal(t, want, got)
+}
+
+func TestDashboardList_Call(t *testing.T) {
+	type ctorArgs struct {
+		storage func(ctx context.Context, ctrl *gomock.Controller) octant.Storage
+	}
+	tests := []struct {
+		name     string
+		ctorArgs ctorArgs
+		call     string
+		wantErr  bool
+	}{
+		{
+			name: "in general",
+			ctorArgs: ctorArgs{
+				storage: func(ctx context.Context, ctrl *gomock.Controller) octant.Storage {
+					objectStore := fake2.NewMockStore(ctrl)
+					objectStore.EXPECT().
+						List(ctx, store.Key{
+							Namespace:  "test",
+							APIVersion: "v1",
+							Kind:       "Pod",
+						}).
+						Return(testutil.ToUnstructuredList(t, testutil.CreatePod("pod")), false, nil)
+					storage := fake.NewMockStorage(ctrl)
+					storage.EXPECT().ObjectStore().Return(objectStore).AnyTimes()
+
+					return storage
+				},
+			},
+			call: `dashClient.List({namespace:'test', apiVersion: 'v1', kind:'Pod'})`,
+		},
+		{
+			name: "list fails",
+			ctorArgs: ctorArgs{
+				storage: func(ctx context.Context, ctrl *gomock.Controller) octant.Storage {
+					objectStore := fake2.NewMockStore(ctrl)
+					objectStore.EXPECT().
+						List(ctx, store.Key{
+							Namespace:  "test",
+							APIVersion: "v1",
+							Kind:       "ReplicaSet",
+						}).
+						Return(nil, false, errors.New("error"))
+					storage := fake.NewMockStorage(ctrl)
+					storage.EXPECT().ObjectStore().Return(objectStore).AnyTimes()
+
+					return storage
+				},
+			},
+			call:    `dashClient.List({namespace:'test', apiVersion: 'v1', kind:'ReplicaSet'})`,
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctrl := gomock.NewController(t)
+			defer ctrl.Finish()
+
+			ctx := context.Background()
+			d := NewDashboardList(tt.ctorArgs.storage(ctx, ctrl))
+
+			runner := functionRunner{wantErr: tt.wantErr}
+			runner.run(ctx, t, d, tt.call)
+
+		})
+	}
+}
diff --git a/pkg/plugin/javascript/dashboard_ref_path_test.go b/pkg/plugin/javascript/dashboard_ref_path_test.go
new file mode 100644
index 00000000..c7583ac1
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_ref_path_test.go
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/internal/octant/fake"
+)
+
+func TestDashboardRefPath_Name(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+	linkGenerator := fake.NewMockLinkGenerator(ctrl)
+
+	d := NewDashboardRefPath(linkGenerator)
+
+	want := "RefPath"
+	got := d.Name()
+
+	require.Equal(t, want, got)
+}
+
+func TestDashboardRefPath_Call(t *testing.T) {
+	type ctorArgs struct {
+		linkGenerator func(ctx context.Context, ctrl *gomock.Controller) octant.LinkGenerator
+	}
+	tests := []struct {
+		name     string
+		ctorArgs ctorArgs
+		call     string
+		wantErr  bool
+	}{
+		{
+			name: "in general",
+			ctorArgs: ctorArgs{
+				linkGenerator: func(ctx context.Context, ctrl *gomock.Controller) octant.LinkGenerator {
+					linkGenerator := fake.NewMockLinkGenerator(ctrl)
+					linkGenerator.EXPECT().
+						ObjectPath("test", "v1", "ReplicaSet", "my-replica-set").
+						Return("/path", nil)
+					return linkGenerator
+				},
+			},
+			call: `dashClient.RefPath({namespace:'test', apiVersion: 'v1', kind:'ReplicaSet', name: 'my-replica-set'})`,
+		},
+		{
+			name: "ref path fails",
+			ctorArgs: ctorArgs{
+				linkGenerator: func(ctx context.Context, ctrl *gomock.Controller) octant.LinkGenerator {
+					linkGenerator := fake.NewMockLinkGenerator(ctrl)
+					linkGenerator.EXPECT().
+						ObjectPath("test", "v1", "ReplicaSet", "my-replica-set").
+						Return("", errors.New("error"))
+					return linkGenerator
+				},
+			},
+			call:    `dashClient.RefPath({namespace:'test', apiVersion: 'v1', kind:'ReplicaSet', name: 'my-replica-set'})`,
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctrl := gomock.NewController(t)
+			defer ctrl.Finish()
+
+			ctx := context.Background()
+			d := NewDashboardRefPath(tt.ctorArgs.linkGenerator(ctx, ctrl))
+
+			runner := functionRunner{wantErr: tt.wantErr}
+			runner.run(ctx, t, d, tt.call)
+		})
+	}
+}
diff --git a/pkg/plugin/javascript/dashboard_update_test.go b/pkg/plugin/javascript/dashboard_update_test.go
new file mode 100644
index 00000000..436daddd
--- /dev/null
+++ b/pkg/plugin/javascript/dashboard_update_test.go
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/internal/octant/fake"
+	fake2 "github.com/vmware-tanzu/octant/pkg/store/fake"
+)
+
+func TestDashboardUpdate_Name(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+	storage := fake.NewMockStorage(ctrl)
+
+	d := NewDashboardUpdate(storage)
+
+	want := "Update"
+	got := d.Name()
+
+	require.Equal(t, want, got)
+}
+
+func TestDashboardUpdate_Call(t *testing.T) {
+	type ctorArgs struct {
+		storage func(ctx context.Context, ctrl *gomock.Controller) octant.Storage
+	}
+	tests := []struct {
+		name     string
+		ctorArgs ctorArgs
+		call     string
+		wantErr  bool
+	}{
+		{
+			name: "in general",
+			ctorArgs: ctorArgs{
+				storage: func(ctx context.Context, ctrl *gomock.Controller) octant.Storage {
+					objectStore := fake2.NewMockStore(ctrl)
+					objectStore.EXPECT().
+						CreateOrUpdateFromYAML(ctx, "test", "create-yaml").
+						Return([]string{"test"}, nil)
+
+					storage := fake.NewMockStorage(ctrl)
+					storage.EXPECT().ObjectStore().Return(objectStore).AnyTimes()
+					return storage
+				},
+			},
+			call: `dashClient.Update('test', 'create-yaml')`,
+		},
+		{
+			name: "create fails",
+			ctorArgs: ctorArgs{
+				storage: func(ctx context.Context, ctrl *gomock.Controller) octant.Storage {
+					objectStore := fake2.NewMockStore(ctrl)
+					objectStore.EXPECT().
+						CreateOrUpdateFromYAML(ctx, "test", "create-yaml").
+						Return(nil, errors.New("error"))
+
+					storage := fake.NewMockStorage(ctrl)
+					storage.EXPECT().ObjectStore().Return(objectStore).AnyTimes()
+
+					return storage
+				},
+			},
+			call:    `dashClient.Update('test', 'create-yaml')`,
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctrl := gomock.NewController(t)
+			defer ctrl.Finish()
+
+			ctx := context.Background()
+			d := NewDashboardUpdate(tt.ctorArgs.storage(ctx, ctrl))
+
+			runner := functionRunner{wantErr: tt.wantErr}
+			runner.run(ctx, t, d, tt.call)
+
+		})
+	}
+}
diff --git a/pkg/plugin/javascript/dashclient_test.go b/pkg/plugin/javascript/dashclient_test.go
deleted file mode 100644
index f65b5789..00000000
--- a/pkg/plugin/javascript/dashclient_test.go
+++ /dev/null
@@ -1,104 +0,0 @@
-package javascript
-
-import (
-	"context"
-	"testing"
-
-	"github.com/dop251/goja"
-	"github.com/golang/mock/gomock"
-	"github.com/stretchr/testify/assert"
-	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
-
-	"github.com/vmware-tanzu/octant/pkg/store"
-	storeFake "github.com/vmware-tanzu/octant/pkg/store/fake"
-)
-
-func TestDashboardClient_Get(t *testing.T) {
-	controller := gomock.NewController(t)
-	defer controller.Finish()
-
-	ctx := context.Background()
-	objectStore := storeFake.NewMockStore(controller)
-
-	vm := goja.New()
-	vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
-
-	obj := CreateDashClientObject(ctx, objectStore, vm)
-	assert.NotNil(t, obj)
-
-	objectStore.EXPECT().Get(ctx, store.Key{APIVersion: "v1", Kind: "Pod"}).Return(&unstructured.Unstructured{}, nil)
-
-	vm.Set("dashClient", obj)
-	_, err := vm.RunString(`
-dashClient.Get({apiVersion: 'v1', kind:'Pod'})
-`)
-	assert.NoError(t, err)
-}
-
-func TestDashboardClient_List(t *testing.T) {
-	controller := gomock.NewController(t)
-	defer controller.Finish()
-
-	ctx := context.Background()
-	objectStore := storeFake.NewMockStore(controller)
-
-	vm := goja.New()
-	vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
-
-	obj := CreateDashClientObject(ctx, objectStore, vm)
-	assert.NotNil(t, obj)
-
-	objectStore.EXPECT().List(ctx, store.Key{Namespace: "test", APIVersion: "v1", Kind: "Pod"}).Return(&unstructured.UnstructuredList{}, false, nil)
-
-	vm.Set("dashClient", obj)
-	_, err := vm.RunString(`
-dashClient.List({namespace:'test', apiVersion: 'v1', kind:'Pod'})
-`)
-	assert.NoError(t, err)
-}
-
-func TestDashboardClient_CreateUpdate(t *testing.T) {
-	controller := gomock.NewController(t)
-	defer controller.Finish()
-
-	ctx := context.Background()
-	objectStore := storeFake.NewMockStore(controller)
-
-	vm := goja.New()
-	vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
-
-	obj := CreateDashClientObject(ctx, objectStore, vm)
-	assert.NotNil(t, obj)
-
-	objectStore.EXPECT().CreateOrUpdateFromYAML(ctx, "test", "create-yaml").Return([]string{}, nil)
-	objectStore.EXPECT().CreateOrUpdateFromYAML(ctx, "test", "update-yaml").Return([]string{}, nil)
-
-	vm.Set("dashClient", obj)
-	_, err := vm.RunString(`
-dashClient.Create('test', 'create-yaml')
-dashClient.Update('test', 'update-yaml')
-`)
-	assert.NoError(t, err)
-}
-
-func TestDashboardClient_Delete(t *testing.T) {
-	controller := gomock.NewController(t)
-	defer controller.Finish()
-
-	ctx := context.Background()
-	objectStore := storeFake.NewMockStore(controller)
-
-	vm := goja.New()
-	vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
-
-	obj := CreateDashClientObject(ctx, objectStore, vm)
-	assert.NotNil(t, obj)
-
-	objectStore.EXPECT().Delete(ctx, store.Key{Namespace: "test", APIVersion: "v1", Kind: "ReplicaSet", Name: "my-replica-set"}).Return(nil)
-
-	vm.Set("dashClient", obj)
-	_, err := vm.RunString(`
-dashClient.Delete({namespace:'test', apiVersion: 'v1', kind:'ReplicaSet', name: 'my-replica-set'})
-`)
-	assert.NoError(t, err)
-}
diff --git a/pkg/plugin/javascript/helpers_test.go b/pkg/plugin/javascript/helpers_test.go
new file mode 100644
index 00000000..1ba3310e
--- /dev/null
+++ b/pkg/plugin/javascript/helpers_test.go
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2020 the Octant contributors. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package javascript
+
+import (
+	"context"
+	"testing"
+
+	"github.com/davecgh/go-spew/spew"
+	"github.com/dop251/goja"
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/vmware-tanzu/octant/internal/octant"
+	"github.com/vmware-tanzu/octant/internal/octant/fake"
+)
+
+type functionRunner struct {
+	wantErr bool
+}
+
+func (fr *functionRunner) run(ctx context.Context, t *testing.T, fn octant.DashboardClientFunction, call string) {
+	vm := goja.New()
+	vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
+
+	obj := vm.NewObject()
+	callFn := fn.Call(ctx, vm)
+	require.NoError(t, obj.Set(fn.Name(), callFn), "set call function")
+
+	vm.Set("dashClient", obj)
+	_, err := vm.RunString(call)
+	spew.Dump(err)
+	if fr.wantErr {
+		require.Error(t, err)
+		return
+	}
+	require.NoError(t, err)
+}
+
+// CreateFn creates a function.
+func createFn(ctx context.Context, ctrl *gomock.Controller, vm *goja.Runtime, name string) octant.DashboardClientFunction {
+	fn := fake.NewMockDashboardClientFunction(ctrl)
+	fn.EXPECT().Name().Return(name)
+	fn.EXPECT().Call(ctx, vm)
+	return fn
+}
diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go
index 2f529646..243a6ebf 100644
--- a/pkg/store/store_test.go
+++ b/pkg/store/store_test.go
@@ -45,6 +45,55 @@ func TestKey_GroupVersionKind(t *testing.T) {
 	assert.Equal(t, gvk.Pod, got)
 }
 
+func TestKey_Validate(t *testing.T) {
+	tests := []struct {
+		name    string
+		key     Key
+		wantErr bool
+	}{
+		{
+			name: "valid with namespace",
+			key: Key{
+				Namespace:  "test",
+				APIVersion: "apiVersion",
+				Kind:       "kind",
+				Name:       "name",
+			},
+		},
+		{
+			name: "valid without namespace",
+			key: Key{
+				APIVersion: "apiVersion",
+				Kind:       "kind",
+				Name:       "name",
+			},
+		},
+		{
+			name: "missing api version",
+			key: Key{
+				Kind: "kind",
+				Name: "name",
+			},
+			wantErr: true,
+		},
+		{
+			name: "missing kind",
+			key: Key{
+				APIVersion: "apiVersion",
+				Name:       "name",
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.key.Validate()
+			testutil.RequireErrorOrNot(t, tt.wantErr, err)
+		})
+	}
+}
+
 func TestKeyFromObject(t *testing.T) {
 	pod := testutil.CreatePod("pod")
 
diff --git a/web/src/app/testing/theme-service-stub.ts b/web/src/app/testing/theme-service-stub.ts
index a82f9cb2..d3d90b24 100644
--- a/web/src/app/testing/theme-service-stub.ts
+++ b/web/src/app/testing/theme-service-stub.ts
@@ -5,6 +5,6 @@ import { ThemeService } from '../modules/sugarloaf/components/smart/theme-switch
 
 export const themeServiceStub: Partial<ThemeService> = {
   loadCSS: () => void 0,
-  loadTheme: () => void 0,
+  loadTheme: () => Promise.resolve(),
   isLightThemeEnabled: () => true,
 };