From a9fd625e031145544d5b7bf884e4d2095c6c5c5e Mon Sep 17 00:00:00 2001 From: bryk Date: Wed, 1 Jun 2016 08:10:24 +0200 Subject: [PATCH] Generic YAML editor for all resources To test this edit a pod in a list. Missing things that will be done in next PR: * make the menu and popup look pretty * make json editor work on serve:prod (now images are missing) --- bower.json | 8 +- build/build.js | 52 ++++++++- build/conf.js | 1 + i18n/messages-en.xtb | 7 ++ i18n/messages-ja.xtb | 7 ++ src/app/backend/handler/apihandler.go | 42 +++++++- src/app/backend/resource/common/verber.go | 63 +++++++++-- .../resourcecard/resourcecard_module.js | 2 + .../resourcecardeditmenuitem.html | 21 ++++ .../resourcecardeditmenuitem_component.js | 77 +++++++++++++ .../common/resource/deleteresource.html | 12 ++- .../resource/deleteresource_controller.js | 22 +++- .../common/resource/editresource.html | 33 ++++++ .../common/resource/editresource.scss | 38 +++++++ .../resource/editresource_controller.js | 102 ++++++++++++++++++ .../common/resource/editresource_dialog.js | 36 +++++++ .../common/resource/resource_module.js | 1 + .../common/resource/verber_service.js | 37 +++++++ src/app/frontend/petsetlist/petsetcard.html | 2 + src/app/frontend/podlist/podcardlist.html | 2 + .../replicasetlist/replicasetcard.html | 2 + .../replicationcontrollercardmenu.html | 2 + .../frontend/servicelist/servicecardlist.html | 2 + .../backend/resource/common/verber_test.go | 58 ++++++++++ .../resource/editresource_controller_test.js | 63 +++++++++++ .../resource/verber_service_test.js | 51 ++++++++- ...resourcecardeditmenuitem_component_test.js | 76 +++++++++++++ 27 files changed, 790 insertions(+), 29 deletions(-) create mode 100644 src/app/frontend/common/components/resourcecard/resourcecardeditmenuitem.html create mode 100644 src/app/frontend/common/components/resourcecard/resourcecardeditmenuitem_component.js create mode 100644 src/app/frontend/common/resource/editresource.html create mode 100644 src/app/frontend/common/resource/editresource.scss create mode 100644 src/app/frontend/common/resource/editresource_controller.js create mode 100644 src/app/frontend/common/resource/editresource_dialog.js create mode 100644 src/test/frontend/common/components/resource/editresource_controller_test.js create mode 100644 src/test/frontend/common/components/resourcecard/resourcecardeditmenuitem_component_test.js diff --git a/bower.json b/bower.json index 5aaf47017080..bfe5012ad719 100644 --- a/bower.json +++ b/bower.json @@ -11,7 +11,8 @@ "angular-resource": "~1.5.0", "angular-sanitize": "~1.5.0", "material-design-icons": "~2.2.2", - "roboto-fontface": "~0.4.5" + "roboto-fontface": "~0.4.5", + "ng-jsoneditor": "angular-tools/ng-jsoneditor#~1.0.0" }, "overrides": { "material-design-icons": { @@ -21,7 +22,10 @@ "main": "css/roboto-fontface.css" }, "google-closure-library": { - "main": ["closure/goog/base.js", "closure/goog/deps.js"] + "main": [ + "closure/goog/base.js", + "closure/goog/deps.js" + ] } }, "devDependencies": { diff --git a/build/build.js b/build/build.js index fd162adc0566..54eb368a61ed 100644 --- a/build/build.js +++ b/build/build.js @@ -88,9 +88,11 @@ gulp.task('locales-for-backend:cross', ['clean-dist'], function() { * Builds production version of the frontend application for the default architecture * (one copy per locale) and plcaes it under .tmp/dist , preparing it for localization and revision. */ -gulp.task('frontend-copies', ['fonts', 'icons', 'assets', 'index:prod', 'clean-dist'], function() { - return createFrontendCopies([path.join(conf.paths.distPre, conf.arch.default, 'public')]); -}); +gulp.task( + 'frontend-copies', + ['fonts', 'icons', 'assets', 'dependency-images', 'index:prod', 'clean-dist'], function() { + return createFrontendCopies([path.join(conf.paths.distPre, conf.arch.default, 'public')]); + }); /** * Builds production versions of the frontend application for all architecures @@ -98,7 +100,15 @@ gulp.task('frontend-copies', ['fonts', 'icons', 'assets', 'index:prod', 'clean-d */ gulp.task( 'frontend-copies:cross', - ['fonts:cross', 'icons:cross', 'assets:cross', 'index:prod', 'clean-dist'], function() { + [ + 'fonts:cross', + 'icons:cross', + 'assets:cross', + 'dependency-images:cross', + 'index:prod', + 'clean-dist', + ], + function() { return createFrontendCopies( conf.arch.list.map((arch) => path.join(conf.paths.distPre, arch, 'public'))); }); @@ -134,6 +144,20 @@ gulp.task('fonts', ['clean-dist'], function() { return fonts([conf.paths.distPub */ gulp.task('fonts:cross', ['clean-dist'], function() { return fonts(conf.paths.distPublicCross); }); +/** + * Copies images from dependencies to the dist directory for current architecture. + */ +gulp.task('dependency-images', ['clean-dist'], function() { + return dependencyImages([conf.paths.distPublic]); +}); + +/** + * Copies images from dependencies to the dist directory for all architectures. + */ +gulp.task('dependency-images:cross', ['clean-dist'], function() { + return dependencyImages(conf.paths.distPublicCross); +}); + /** * Cleans all build artifacts. */ @@ -175,7 +199,13 @@ function createFrontendCopies(outputDirs) { return gulp.src(path.join(conf.paths.prodTmp, '*.html')) .pipe(gulpUseref({searchPath: searchPath})) .pipe(gulpIf('**/vendor.css', gulpMinifyCss())) - .pipe(gulpIf('**/vendor.js', gulpUglify({preserveComments: uglifySaveLicense}))) + .pipe(gulpIf('**/vendor.js', gulpUglify({ + preserveComments: uglifySaveLicense, + // Disable compression of unused vars. This speeds up minification a lot (like + // 10 times). + // See https://github.com/mishoo/UglifyJS2/issues/321 + compress: {unused: false}, + }))) .pipe(gulpIf('*.html', gulpHtmlmin({ removeComments: true, collapseWhitespace: true, @@ -265,6 +295,18 @@ function fonts(outputDirs) { .pipe(multiDest(localizedOutputDirs)); } +/** + * Copies the font files to all dist directories per arch and locale. + * @param {!Array} outputDirs + * @return {stream} + */ +function dependencyImages(outputDirs) { + let localizedOutputDirs = createLocalizedOutputs(outputDirs, 'static/img'); + return gulp + .src(path.join(conf.paths.jsoneditorImages, '*.png'), {base: conf.paths.jsoneditorImages}) + .pipe(multiDest(localizedOutputDirs)); +} + /** * Returns one subdirectory path for each supported locale inside all of the specified * outputDirs. Optionally, a subdirectory structure can be passed to append after each locale path. diff --git a/build/conf.js b/build/conf.js index a5e5bf433f9c..c398dffa2654 100644 --- a/build/conf.js +++ b/build/conf.js @@ -196,6 +196,7 @@ export default { hyperkube: path.join(basePath, 'build/hyperkube.sh'), i18nProd: path.join(basePath, '.tmp/i18n'), integrationTest: path.join(basePath, 'src/test/integration'), + jsoneditorImages: path.join(basePath, 'bower_components/jsoneditor/src/css/img'), karmaConf: path.join(basePath, 'build/karma.conf.js'), materialIcons: path.join(basePath, 'bower_components/material-design-icons/iconfont'), nodeModules: path.join(basePath, 'node_modules'), diff --git a/i18n/messages-en.xtb b/i18n/messages-en.xtb index d74b2a5ba6eb..1d243f3b0db6 100644 --- a/i18n/messages-en.xtb +++ b/i18n/messages-en.xtb @@ -155,4 +155,11 @@ Status Pods Pods status + Delete a + Cancel + Delete + Edit a + Cancel + Update + View/edit JSON \ No newline at end of file diff --git a/i18n/messages-ja.xtb b/i18n/messages-ja.xtb index 005f02319d5f..b38d88880e5a 100644 --- a/i18n/messages-ja.xtb +++ b/i18n/messages-ja.xtb @@ -155,4 +155,11 @@ Status Pods Pods status + Delete a + Cancel + Delete + Edit a + Cancel + Update + View/edit JSON \ No newline at end of file diff --git a/src/app/backend/handler/apihandler.go b/src/app/backend/handler/apihandler.go index 5f3ce1b8fa11..bb220c49bb91 100644 --- a/src/app/backend/handler/apihandler.go +++ b/src/app/backend/handler/apihandler.go @@ -40,6 +40,7 @@ import ( . "github.com/kubernetes/dashboard/validation" client "k8s.io/kubernetes/pkg/client/unversioned" "k8s.io/kubernetes/pkg/client/unversioned/clientcmd" + "k8s.io/kubernetes/pkg/runtime" ) const ( @@ -295,7 +296,12 @@ func CreateHttpApiHandler(client *client.Client, heapsterClient HeapsterClient, apiV1Ws.Route( apiV1Ws.DELETE("/{kind}/namespace/{namespace}/name/{name}"). To(apiHandler.handleDeleteResource)) - + apiV1Ws.Route( + apiV1Ws.GET("/{kind}/namespace/{namespace}/name/{name}"). + To(apiHandler.handleGetResource)) + apiV1Ws.Route( + apiV1Ws.PUT("/{kind}/namespace/{namespace}/name/{name}"). + To(apiHandler.handlePutResource)) return wsContainer } @@ -617,6 +623,40 @@ func (apiHandler *ApiHandler) handleDeleteReplicationController( response.WriteHeader(http.StatusOK) } +func (apiHandler *ApiHandler) handleGetResource( + request *restful.Request, response *restful.Response) { + kind := request.PathParameter("kind") + namespace := request.PathParameter("namespace") + name := request.PathParameter("name") + + result, err := apiHandler.verber.Get(kind, namespace, name) + if err != nil { + handleInternalError(response, err) + return + } + + response.WriteHeaderAndEntity(http.StatusCreated, result) +} + +func (apiHandler *ApiHandler) handlePutResource( + request *restful.Request, response *restful.Response) { + kind := request.PathParameter("kind") + namespace := request.PathParameter("namespace") + name := request.PathParameter("name") + putSpec := &runtime.Unknown{} + if err := request.ReadEntity(putSpec); err != nil { + handleInternalError(response, err) + return + } + + if err := apiHandler.verber.Put(kind, namespace, name, putSpec); err != nil { + handleInternalError(response, err) + return + } + + response.WriteHeader(http.StatusOK) +} + func (apiHandler *ApiHandler) handleDeleteResource( request *restful.Request, response *restful.Response) { kind := request.PathParameter("kind") diff --git a/src/app/backend/resource/common/verber.go b/src/app/backend/resource/common/verber.go index da237e02250f..3b1d9e908604 100644 --- a/src/app/backend/resource/common/verber.go +++ b/src/app/backend/resource/common/verber.go @@ -18,6 +18,7 @@ import ( "fmt" "k8s.io/kubernetes/pkg/client/restclient" + "k8s.io/kubernetes/pkg/runtime" ) // ResourceVerber is a struct responsible for doing common verb operations on resources, like @@ -29,9 +30,24 @@ type ResourceVerber struct { batchClient RESTClient } +func (verber *ResourceVerber) getRESTClientByType(clientType ClientType) RESTClient { + switch clientType { + case ClientTypeExtensionClient: + return verber.extensionsClient + case ClientTypeAppsClient: + return verber.appsClient + case ClientTypeBatchClient: + return verber.batchClient + default: + return verber.client + } +} + // RESTClient is an interface for REST operations used in this file. type RESTClient interface { Delete() *restclient.Request + Put() *restclient.Request + Get() *restclient.Request } // NewResourceVerber creates a new resource verber that uses the given client for performing @@ -58,15 +74,42 @@ func (verber *ResourceVerber) Delete(kind string, namespace string, name string) Error() } -func (verber *ResourceVerber) getRESTClientByType(clientType ClientType) RESTClient { - switch clientType { - case ClientTypeExtensionClient: - return verber.extensionsClient - case ClientTypeAppsClient: - return verber.appsClient - case ClientTypeBatchClient: - return verber.batchClient - default: - return verber.client +// Put puts new resource version of the given kind in the given namespace with the given name. +func (verber *ResourceVerber) Put(kind string, namespace string, name string, + object runtime.Object) error { + + resourceSpec, ok := kindToAPIMapping[kind] + if !ok { + return fmt.Errorf("Unknown resource kind: %s", kind) + } + + client := verber.getRESTClientByType(resourceSpec.ClientType) + + return client.Put(). + Namespace(namespace). + Resource(resourceSpec.Resource). + Name(name). + Body(object). + Do(). + Error() +} + +// Get gets the resource of the given kind in the given namespace with the given name. +func (verber *ResourceVerber) Get(kind string, namespace string, name string) (runtime.Object, error) { + resourceSpec, ok := kindToAPIMapping[kind] + if !ok { + return nil, fmt.Errorf("Unknown resource kind: %s", kind) } + + client := verber.getRESTClientByType(resourceSpec.ClientType) + + result := &runtime.Unknown{} + err := client.Get(). + Namespace(namespace). + Resource(resourceSpec.Resource). + Name(name). + Do(). + Into(result) + + return result, err } diff --git a/src/app/frontend/common/components/resourcecard/resourcecard_module.js b/src/app/frontend/common/components/resourcecard/resourcecard_module.js index 7d3ee6286c64..1fa386913505 100644 --- a/src/app/frontend/common/components/resourcecard/resourcecard_module.js +++ b/src/app/frontend/common/components/resourcecard/resourcecard_module.js @@ -17,6 +17,7 @@ import {resourceCardComponent} from './resourcecard_component'; import {resourceCardListComponent} from './resourcecardlist_component'; import {resourceCardMenuComponent} from './resourcecardmenu_component'; import {resourceCardDeleteMenuItemComponent} from './resourcecarddeletemenuitem_component'; +import {resourceCardEditMenuItemComponent} from './resourcecardeditmenuitem_component'; import {resourceCardColumnComponent} from './resourcecardcolumn_component'; import {resourceCardColumnsComponent} from './resourcecardcolumns_component'; import {resourceCardHeaderColumnComponent} from './resourcecardheadercolumn_component'; @@ -39,6 +40,7 @@ export default angular .component('kdResourceCardList', resourceCardListComponent) .component('kdResourceCardMenu', resourceCardMenuComponent) .component('kdResourceCardDeleteMenuItem', resourceCardDeleteMenuItemComponent) + .component('kdResourceCardEditMenuItem', resourceCardEditMenuItemComponent) .component('kdResourceCardColumn', resourceCardColumnComponent) .component('kdResourceCardColumns', resourceCardColumnsComponent) .component('kdResourceCardHeaderColumn', resourceCardHeaderColumnComponent) diff --git a/src/app/frontend/common/components/resourcecard/resourcecardeditmenuitem.html b/src/app/frontend/common/components/resourcecard/resourcecardeditmenuitem.html new file mode 100644 index 000000000000..bee34bc59555 --- /dev/null +++ b/src/app/frontend/common/components/resourcecard/resourcecardeditmenuitem.html @@ -0,0 +1,21 @@ + + + + + {{::$ctrl.i18n.MSG_YAML_EDIT_MENU_LABEL}} + + diff --git a/src/app/frontend/common/components/resourcecard/resourcecardeditmenuitem_component.js b/src/app/frontend/common/components/resourcecard/resourcecardeditmenuitem_component.js new file mode 100644 index 000000000000..b966b2fa18a4 --- /dev/null +++ b/src/app/frontend/common/components/resourcecard/resourcecardeditmenuitem_component.js @@ -0,0 +1,77 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Controller for the resource card delete menu item component. It deletes card's resource. + * @final + */ +export class ResourceCardEditMenuItemController { + /** + * @param {!./../../resource/verber_service.VerberService} kdResourceVerberService + * @param {!ui.router.$state} $state + * @ngInject + */ + constructor(kdResourceVerberService, $state) { + /** + * Initialized from require just before $onInit is called. + * @export {!./resourcecard_component.ResourceCardController} + */ + this.resourceCardCtrl; + + /** @export {string} Initialized from a binding.*/ + this.resourceKindName; + + /** @private {!./../../resource/verber_service.VerberService} */ + this.kdResourceVerberService_ = kdResourceVerberService; + + /** @private {!ui.router.$state}} */ + this.state_ = $state; + + /** @export */ + this.i18n = i18n; + } + + /** + * @export + */ + edit() { + this.kdResourceVerberService_ + .showEditDialog( + this.resourceKindName, this.resourceCardCtrl.typeMeta, this.resourceCardCtrl.objectMeta) + .then(() => { + // For now just reload the state. Later we can update the item in place. + this.state_.reload(); + }); + } +} + +/** + * @type {!angular.Component} + */ +export const resourceCardEditMenuItemComponent = { + templateUrl: 'common/components/resourcecard/resourcecardeditmenuitem.html', + bindings: { + 'resourceKindName': '@', + }, + bindToController: true, + require: { + 'resourceCardCtrl': '^kdResourceCard', + }, + controller: ResourceCardEditMenuItemController, +}; + +const i18n = { + /** @export @desc Label for YAML edit menu item. */ + MSG_YAML_EDIT_MENU_LABEL: goog.getMsg('View/edit JSON'), +}; diff --git a/src/app/frontend/common/resource/deleteresource.html b/src/app/frontend/common/resource/deleteresource.html index 02798056d193..0d9123e03067 100644 --- a/src/app/frontend/common/resource/deleteresource.html +++ b/src/app/frontend/common/resource/deleteresource.html @@ -14,17 +14,21 @@ limitations under the License. --> - + -

Delete a {{$ctrl.resourceKindName}}

+

{{::$ctrl.i18n.MSG_DELETE_RESOURCE_DIALOG_TITLE}}

Are you sure you want to delete {{$ctrl.resourceKindName}} in namespace {{$ctrl.objectMeta.namespace}}?
- Cancel - Delete + + {{::$ctrl.i18n.MSG_DELETE_RESOURCE_DIALOG_CANCEL}} + + + {{::$ctrl.i18n.MSG_DELETE_RESOURCE_DIALOG_DELETE}} +
diff --git a/src/app/frontend/common/resource/deleteresource_controller.js b/src/app/frontend/common/resource/deleteresource_controller.js index 923f98eeed1d..39d37835c9ba 100644 --- a/src/app/frontend/common/resource/deleteresource_controller.js +++ b/src/app/frontend/common/resource/deleteresource_controller.js @@ -27,9 +27,6 @@ export class DeleteResourceController { * @ngInject */ constructor($mdDialog, $resource, resourceKindName, typeMeta, objectMeta) { - /** @export {string} */ - this.resourceKindName = resourceKindName; - /** @private {!backendApi.TypeMeta} */ this.typeMeta_ = typeMeta; @@ -41,6 +38,9 @@ export class DeleteResourceController { /** @private {!angular.$resource} */ this.resource_ = $resource; + + /** @export */ + this.i18n = i18n(resourceKindName); } /** @@ -59,3 +59,19 @@ export class DeleteResourceController { */ cancel() { this.mdDialog_.cancel(); } } + +/** + * @param {string} resourceKindName + * @return {!Object} + */ +function i18n(resourceKindName) { + return { + /** @export @desc Title for a delete resource dialog */ + MSG_DELETE_RESOURCE_DIALOG_TITLE: + goog.getMsg('Delete a {$resourceKindName}', {'resourceKindName': resourceKindName}), + /** @export @desc Label for cancel button */ + MSG_DELETE_RESOURCE_DIALOG_CANCEL: goog.getMsg('Cancel'), + /** @export @desc Label for delete button */ + MSG_DELETE_RESOURCE_DIALOG_DELETE: goog.getMsg('Delete'), + }; +} diff --git a/src/app/frontend/common/resource/editresource.html b/src/app/frontend/common/resource/editresource.html new file mode 100644 index 000000000000..63c38e5ae30a --- /dev/null +++ b/src/app/frontend/common/resource/editresource.html @@ -0,0 +1,33 @@ + + + + +

{{::$ctrl.i18n.MSG_EDIT_RESOURCE_DIALOG_TITLE}}

+
+
+ + + {{::$ctrl.i18n.MSG_EDIT_RESOURCE_DIALOG_CANCEL}} + + + {{::$ctrl.i18n.MSG_EDIT_RESOURCE_DIALOG_UPDATE}} + + +
+
diff --git a/src/app/frontend/common/resource/editresource.scss b/src/app/frontend/common/resource/editresource.scss new file mode 100644 index 000000000000..63dbb26af8eb --- /dev/null +++ b/src/app/frontend/common/resource/editresource.scss @@ -0,0 +1,38 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + + +.kd-json-edit-dialog-content { + display: flex; + flex-direction: column; +} + +.kd-json-edit-editor { + display: flex; + flex: 1; + overflow: hidden; +} + +.jsoneditor { + display: flex; + flex: 1; + flex-direction: column; + height: initial; + overflow: hidden; + + .outer { + display: flex; + flex: 1; + } +} diff --git a/src/app/frontend/common/resource/editresource_controller.js b/src/app/frontend/common/resource/editresource_controller.js new file mode 100644 index 000000000000..28059c9deb7b --- /dev/null +++ b/src/app/frontend/common/resource/editresource_controller.js @@ -0,0 +1,102 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Controller for the edit resource dialog. + * + * @final + */ +export class EditResourceController { + /** + * @param {!md.$dialog} $mdDialog + * @param {!angular.$resource} $resource + * @param {!angular.$http} $http + * @param {string} resourceKindName + * @param {!backendApi.TypeMeta} typeMeta + * @param {!backendApi.ObjectMeta} objectMeta + * @ngInject + */ + constructor($mdDialog, $resource, $http, resourceKindName, typeMeta, objectMeta) { + /** @export {string} */ + this.resourceKindName = resourceKindName; + + /** @export {Object} JSON representation of the edited resource. */ + this.data = null; + + /** @private {!backendApi.TypeMeta} */ + this.typeMeta_ = typeMeta; + + /** @export {!backendApi.ObjectMeta} */ + this.objectMeta = objectMeta; + + /** @private {!md.$dialog} */ + this.mdDialog_ = $mdDialog; + + /** @private {!angular.$resource} */ + this.resource_ = $resource; + + /** @private {!angular.$http} */ + this.http_ = $http; + + /** @export */ + this.i18n = i18n(resourceKindName); + + this.init_(); + } + + /** + * @private + */ + init_() { + let promise = this.http_.get( + `api/v1/${this.typeMeta_.kind}/namespace/` + `${this.objectMeta.namespace}/name/${this.objectMeta.name}`); + promise.then( + (/** !angular.$http.Response*/ response) => { this.data = response.data; }); + } + + /** + * @export + */ + update() { + return this.http_ + .put( + `api/v1/${this.typeMeta_.kind}/namespace` + + `/${this.objectMeta.namespace}/name/${this.objectMeta.name}`, + this.data) + .then(this.mdDialog_.hide, this.mdDialog_.cancel); + } + + /** + * Cancels and closes the dialog. + * + * @export + */ + cancel() { this.mdDialog_.cancel(); } +} + +/** + * @param {string} resourceKindName + * @return {!Object} + */ +function i18n(resourceKindName) { + return { + /** @export @desc Title for a delete resource dialog */ + MSG_EDIT_RESOURCE_DIALOG_TITLE: + goog.getMsg('Edit a {$resourceKindName}', {'resourceKindName': resourceKindName}), + /** @export @desc Label for cancel button */ + MSG_EDIT_RESOURCE_DIALOG_CANCEL: goog.getMsg('Cancel'), + /** @export @desc Label for update button */ + MSG_EDIT_RESOURCE_DIALOG_UPDATE: goog.getMsg('Update'), + }; +} diff --git a/src/app/frontend/common/resource/editresource_dialog.js b/src/app/frontend/common/resource/editresource_dialog.js new file mode 100644 index 000000000000..1ad2913fb905 --- /dev/null +++ b/src/app/frontend/common/resource/editresource_dialog.js @@ -0,0 +1,36 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {EditResourceController} from './editresource_controller'; + +/** + * @param {!md.$dialog} mdDialog + * @param {string} resourceKindName + * @param {!backendApi.TypeMeta} typeMeta + * @param {!backendApi.ObjectMeta} objectMeta + * @return {!angular.$q.Promise} + */ +export default function showEditDialog(mdDialog, resourceKindName, typeMeta, objectMeta) { + return mdDialog.show({ + controller: EditResourceController, + controllerAs: '$ctrl', + clickOutsideToClose: true, + templateUrl: 'common/resource/editresource.html', + locals: { + 'typeMeta': typeMeta, + 'objectMeta': objectMeta, + 'resourceKindName': resourceKindName, + }, + }); +} diff --git a/src/app/frontend/common/resource/resource_module.js b/src/app/frontend/common/resource/resource_module.js index a20fff6c0cbd..a532dad8367a 100644 --- a/src/app/frontend/common/resource/resource_module.js +++ b/src/app/frontend/common/resource/resource_module.js @@ -25,5 +25,6 @@ export default angular 'ngMaterial', 'ui.router', 'ngResource', + 'ng.jsoneditor', ]) .service('kdResourceVerberService', VerberService); diff --git a/src/app/frontend/common/resource/verber_service.js b/src/app/frontend/common/resource/verber_service.js index 6bb0750cde24..afd82b85cd37 100644 --- a/src/app/frontend/common/resource/verber_service.js +++ b/src/app/frontend/common/resource/verber_service.js @@ -13,6 +13,7 @@ // limitations under the License. import showDeleteDialog from './deleteresource_dialog'; +import showEditDialog from './editresource_dialog'; /** * Verber service for performing common verb operations on resources, e.g., deleting or editing @@ -54,6 +55,27 @@ export class VerberService { return deferred.promise; } + /** + * Opens a resource update dialog. Returns a promise that is resolved/rejected when + * user wants to update the resource. Nothing happens when user clicks cancel on the dialog. + * @param {string} resourceKindName + * @param {!backendApi.TypeMeta} typeMeta + * @param {!backendApi.ObjectMeta} objectMeta + * @return {!angular.$q.Promise} + */ + showEditDialog(resourceKindName, typeMeta, objectMeta) { + let deferred = this.q_.defer(); + + showEditDialog(this.mdDialog_, resourceKindName, typeMeta, objectMeta) + .then(() => { deferred.resolve(); }) + .catch((err) => { + this.editErrorCallback(err); + deferred.reject(err); + }); + + return deferred.promise; + } + /** * Callback function to show dialog with error message if resource deletion fails. * @@ -68,4 +90,19 @@ export class VerberService { .textContent(err.data || 'Could not delete the resource')); } } + + /** + * Callback function to show dialog with error message if resource edit fails. + * + * @param {angular.$http.Response|null} err + */ + editErrorCallback(err) { + if (err) { + // Show dialog if there was an error, not user canceling dialog. + this.mdDialog_.show(this.mdDialog_.alert() + .ok('Ok') + .title(err.statusText || 'Internal server error') + .textContent(err.data || 'Could not edit the resource')); + } + } } diff --git a/src/app/frontend/petsetlist/petsetcard.html b/src/app/frontend/petsetlist/petsetcard.html index f20e3132525a..f5603f0aef70 100644 --- a/src/app/frontend/petsetlist/petsetcard.html +++ b/src/app/frontend/petsetlist/petsetcard.html @@ -63,6 +63,8 @@ + + diff --git a/src/app/frontend/podlist/podcardlist.html b/src/app/frontend/podlist/podcardlist.html index 5883db1c71ef..713bf875264a 100644 --- a/src/app/frontend/podlist/podcardlist.html +++ b/src/app/frontend/podlist/podcardlist.html @@ -81,6 +81,8 @@ + + diff --git a/src/app/frontend/replicasetlist/replicasetcard.html b/src/app/frontend/replicasetlist/replicasetcard.html index 0897457a5358..cbec809a5bdd 100644 --- a/src/app/frontend/replicasetlist/replicasetcard.html +++ b/src/app/frontend/replicasetlist/replicasetcard.html @@ -65,6 +65,8 @@ + + diff --git a/src/app/frontend/replicationcontrollerlist/replicationcontrollercardmenu.html b/src/app/frontend/replicationcontrollerlist/replicationcontrollercardmenu.html index d02bf540681b..ae87e392a19e 100644 --- a/src/app/frontend/replicationcontrollerlist/replicationcontrollercardmenu.html +++ b/src/app/frontend/replicationcontrollerlist/replicationcontrollercardmenu.html @@ -27,4 +27,6 @@ + + diff --git a/src/app/frontend/servicelist/servicecardlist.html b/src/app/frontend/servicelist/servicecardlist.html index 7fd6d35b5404..635f35052b28 100644 --- a/src/app/frontend/servicelist/servicecardlist.html +++ b/src/app/frontend/servicelist/servicecardlist.html @@ -54,6 +54,8 @@ + + diff --git a/src/test/backend/resource/common/verber_test.go b/src/test/backend/resource/common/verber_test.go index f637c5481482..03f58604a146 100644 --- a/src/test/backend/resource/common/verber_test.go +++ b/src/test/backend/resource/common/verber_test.go @@ -26,6 +26,18 @@ func (c *FakeRESTClient) Delete() *restclient.Request { }), "DELETE", nil, "/api/v1", restclient.ContentConfig{}, restclient.Serializers{}, nil, nil) } +func (c *FakeRESTClient) Put() *restclient.Request { + return restclient.NewRequest(clientFunc(func(req *http.Request) (*http.Response, error) { + return c.response, c.err + }), "PUT", nil, "/api/v1", restclient.ContentConfig{}, restclient.Serializers{}, nil, nil) +} + +func (c *FakeRESTClient) Get() *restclient.Request { + return restclient.NewRequest(clientFunc(func(req *http.Request) (*http.Response, error) { + return c.response, c.err + }), "GET", nil, "/api/v1", restclient.ContentConfig{}, restclient.Serializers{}, nil, nil) +} + func TestDeleteShouldPropagateErrorsAndChoseClient(t *testing.T) { verber := ResourceVerber{ client: &FakeRESTClient{err: errors.New("err")}, @@ -52,6 +64,32 @@ func TestDeleteShouldPropagateErrorsAndChoseClient(t *testing.T) { } } +func TestGetShouldPropagateErrorsAndChoseClient(t *testing.T) { + verber := ResourceVerber{ + client: &FakeRESTClient{err: errors.New("err")}, + extensionsClient: &FakeRESTClient{err: errors.New("err from extensions")}, + appsClient: &FakeRESTClient{err: errors.New("err from apps")}, + } + + _, err := verber.Get("replicaset", "bar", "baz") + + if !reflect.DeepEqual(err, errors.New("err from extensions")) { + t.Fatalf("Expected error on verber delete but got %#v", err) + } + + _, err = verber.Get("service", "bar", "baz") + + if !reflect.DeepEqual(err, errors.New("err")) { + t.Fatalf("Expected error on verber delete but got %#v", err) + } + + _, err = verber.Get("petset", "bar", "baz") + + if !reflect.DeepEqual(err, errors.New("err from apps")) { + t.Fatalf("Expected error on verber delete but got %#v", err) + } +} + func TestDeleteShouldThrowErrorOnUnknownResourceKind(t *testing.T) { verber := ResourceVerber{client: &FakeRESTClient{}} @@ -61,3 +99,23 @@ func TestDeleteShouldThrowErrorOnUnknownResourceKind(t *testing.T) { t.Fatalf("Expected error on verber delete but got %#v", err) } } + +func TestGetShouldThrowErrorOnUnknownResourceKind(t *testing.T) { + verber := ResourceVerber{client: &FakeRESTClient{}} + + _, err := verber.Get("foo", "bar", "baz") + + if !reflect.DeepEqual(err, errors.New("Unknown resource kind: foo")) { + t.Fatalf("Expected error on verber get but got %#v", err) + } +} + +func TestPutShouldThrowErrorOnUnknownResourceKind(t *testing.T) { + verber := ResourceVerber{client: &FakeRESTClient{}} + + err := verber.Put("foo", "bar", "baz", nil) + + if !reflect.DeepEqual(err, errors.New("Unknown resource kind: foo")) { + t.Fatalf("Expected error on verber put but got %#v", err) + } +} diff --git a/src/test/frontend/common/components/resource/editresource_controller_test.js b/src/test/frontend/common/components/resource/editresource_controller_test.js new file mode 100644 index 000000000000..f7b8aa2be554 --- /dev/null +++ b/src/test/frontend/common/components/resource/editresource_controller_test.js @@ -0,0 +1,63 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import resourceModule from 'common/resource/resource_module'; +import {EditResourceController} from 'common/resource/editresource_controller'; + +describe('Edit resource controller', () => { + /** @type !{!common/resource/editresource_controller.EditResourceController} */ + let ctrl; + /** @type {!md.$dialog} */ + let mdDialog; + /** @type {!angular.$httpBackend} */ + let httpBackend; + + beforeEach(() => angular.mock.module(resourceModule.name)); + + beforeEach(angular.mock.inject(($controller, $mdDialog, $httpBackend) => { + ctrl = $controller(EditResourceController, { + resourceKindName: 'My Resource', + objectMeta: {name: 'Foo', namespace: 'Bar'}, + typeMeta: {kind: 'qux'}, + }); + mdDialog = $mdDialog; + httpBackend = $httpBackend; + })); + + it('should edit resource', () => { + spyOn(mdDialog, 'hide'); + ctrl.update(); + let data = {'foo': 'bar'}; + httpBackend.expectGET('api/v1/qux/namespace/Bar/name/Foo').respond(200, data); + httpBackend.expectPUT('api/v1/qux/namespace/Bar/name/Foo').respond(200, {ok: 'ok'}); + httpBackend.flush(); + expect(mdDialog.hide).toHaveBeenCalled(); + }); + + it('should propagate errors', () => { + spyOn(mdDialog, 'cancel'); + ctrl.update(); + let data = {'foo': 'bar'}; + httpBackend.expectGET('api/v1/qux/namespace/Bar/name/Foo').respond(200, data); + httpBackend.expectPUT('api/v1/qux/namespace/Bar/name/Foo').respond(500, {err: 'err'}); + httpBackend.flush(); + expect(mdDialog.cancel).toHaveBeenCalled(); + }); + + it('should cancel', () => { + spyOn(mdDialog, 'cancel'); + ctrl.cancel(); + expect(mdDialog.cancel).toHaveBeenCalled(); + }); +}); diff --git a/src/test/frontend/common/components/resource/verber_service_test.js b/src/test/frontend/common/components/resource/verber_service_test.js index c58e07c74a0c..1559628eee31 100644 --- a/src/test/frontend/common/components/resource/verber_service_test.js +++ b/src/test/frontend/common/components/resource/verber_service_test.js @@ -36,11 +36,11 @@ describe('Verber service', () => { state = $state; })); - it('should show delete dialog resource', () => { + it('should show delete dialog resource', (doneFn) => { let deferred = q.defer(); spyOn(mdDialog, 'show').and.returnValue(deferred.promise); - verber.showDeleteDialog('Foo resource', {foo: 'bar'}, {baz: 'qux'}); + let promise = verber.showDeleteDialog('Foo resource', {foo: 'bar'}, {baz: 'qux'}); expect(mdDialog.show).toHaveBeenCalledWith(jasmine.objectContaining({ locals: { @@ -49,18 +49,61 @@ describe('Verber service', () => { 'objectMeta': {baz: 'qux'}, }, })); + + deferred.resolve(); + promise.then(doneFn); + scope.$digest(); }); - it('should show alert window on error', () => { + it('should show alert window on delete error', (doneFn) => { let deferred = q.defer(); spyOn(mdDialog, 'show').and.returnValue(deferred.promise); spyOn(state, 'reload'); spyOn(mdDialog, 'alert').and.callThrough(); - verber.showDeleteDialog(); + let promise = verber.showDeleteDialog(); deferred.reject({data: 'foo-data', statusText: 'foo-text'}); scope.$digest(); expect(state.reload).not.toHaveBeenCalled(); expect(mdDialog.alert).toHaveBeenCalled(); + + deferred.resolve(); + promise.catch(doneFn); + scope.$digest(); + }); + + it('should show edit dialog resource', (doneFn) => { + let deferred = q.defer(); + spyOn(mdDialog, 'show').and.returnValue(deferred.promise); + + let promise = verber.showEditDialog('Foo resource', {foo: 'bar'}, {baz: 'qux'}); + + expect(mdDialog.show).toHaveBeenCalledWith(jasmine.objectContaining({ + locals: { + 'resourceKindName': 'Foo resource', + 'typeMeta': {foo: 'bar'}, + 'objectMeta': {baz: 'qux'}, + }, + })); + deferred.resolve(); + promise.then(doneFn); + scope.$digest(); + }); + + it('should show alert window on edit error', (doneFn) => { + let deferred = q.defer(); + spyOn(mdDialog, 'show').and.returnValue(deferred.promise); + spyOn(state, 'reload'); + spyOn(mdDialog, 'alert').and.callThrough(); + let promise = verber.showEditDialog(); + + deferred.reject({data: 'foo-data', statusText: 'foo-text'}); + scope.$digest(); + expect(state.reload).not.toHaveBeenCalled(); + expect(mdDialog.alert).toHaveBeenCalled(); + + deferred.resolve(); + promise.catch(doneFn); + scope.$digest(); }); }); diff --git a/src/test/frontend/common/components/resourcecard/resourcecardeditmenuitem_component_test.js b/src/test/frontend/common/components/resourcecard/resourcecardeditmenuitem_component_test.js new file mode 100644 index 000000000000..e7a30bf2d9a5 --- /dev/null +++ b/src/test/frontend/common/components/resourcecard/resourcecardeditmenuitem_component_test.js @@ -0,0 +1,76 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import resourceCardModule from 'common/components/resourcecard/resourcecard_module'; + +describe('Edit resource menu item', () => { + /** @type + * {!common/components/resourcecard/resourcecardeditmenuitem_component.ResourceCardEditMenuItemController} + */ + let ctrl; + /** @type {!angular.$q} */ + let q; + /** @type {!angular.Scope} */ + let scope; + /** @type {!ui.router.$state} */ + let state; + /** @type {!common/resource/verber_service.VerberService} */ + let kdResourceVerberService; + /** @type {!md.$dialog}*/ + let mdDialog; + + beforeEach(() => { + angular.mock.module(resourceCardModule.name); + + angular.mock.inject( + ($rootScope, $componentController, _kdResourceVerberService_, $q, $state, $mdDialog) => { + ctrl = $componentController('kdResourceCardEditMenuItem'); + ctrl.resourceCardCtrl = { + objectMeta: {name: 'foo-name', namespace: 'foo-namespace'}, + typeMeta: {kind: 'foo'}, + }; + state = $state; + kdResourceVerberService = _kdResourceVerberService_; + scope = $rootScope; + q = $q; + mdDialog = $mdDialog; + }); + }); + + it('should edit the resource', () => { + let deferred = q.defer(); + let httpStatusOk = 200; + spyOn(kdResourceVerberService, 'showEditDialog').and.returnValue(deferred.promise); + spyOn(state, 'reload'); + ctrl.edit(); + + expect(state.reload).not.toHaveBeenCalled(); + deferred.resolve(httpStatusOk); + scope.$digest(); + expect(state.reload).toHaveBeenCalled(); + }); + + it('should ignore cancels', () => { + let deferred = q.defer(); + spyOn(kdResourceVerberService, 'showEditDialog').and.returnValue(deferred.promise); + spyOn(state, 'reload'); + spyOn(mdDialog, 'alert').and.callThrough(); + ctrl.edit(); + + deferred.reject(); + scope.$digest(); + expect(state.reload).not.toHaveBeenCalled(); + expect(mdDialog.alert).not.toHaveBeenCalled(); + }); +});