ui-modules/blueprint-composer/app/components/catalog-saver/catalog-saver.directive.js (337 lines of code) (raw):
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 angular from 'angular';
import angularAnimate from 'angular-animate';
import uibModal from 'angular-ui-bootstrap/src/modal/index-nocss';
import template from './catalog-saver.template.html';
import modalTemplate from './catalog-saver.modal.template.html';
import jsYaml from 'js-yaml';
import brUtils from 'brooklyn-ui-utils/utils/general';
const MODULE_NAME = 'brooklyn.components.catalog-saver';
const TEMPLATE_URL = 'blueprint-composer/component/catalog-saver/index.html';
const TEMPLATE_MODAL_URL = 'blueprint-composer/component/catalog-saver/modal.html';
const REASONS = {
new: 0,
deploy: 1,
continue: 2,
};
const VIEWS = {
form: 0,
saved: 1
};
const TYPES = [
{id: 'template', label: 'Application'},
{id: 'entity', label: 'Entity'}
];
// only alphanumerics and/or '.', '-', '_' characters
const VALID_FIELD_PATTERN = /^[\w\.\-\_]+$/;
angular.module(MODULE_NAME, [angularAnimate, uibModal, brUtils])
.directive('catalogSaver', ['$rootScope', '$uibModal', '$injector', '$filter', 'composerOverrides', 'blueprintService', saveToCatalogModalDirective])
.directive('catalogVersion', ['$parse', catalogVersionDirective])
.directive('composerBlueprintNameValidator', composerBlueprintNameValidatorDirective)
.filter('bundlize', bundlizeProvider)
.run(['$templateCache', templateCache]);
export default MODULE_NAME;
export function saveToCatalogModalDirective($rootScope, $uibModal, $injector, $filter, composerOverrides, blueprintService) {
return {
restrict: 'E',
templateUrl: function (tElement, tAttrs) {
return tAttrs.templateUrl || TEMPLATE_URL;
},
scope: {
config: '=',
},
link: link
};
/*
* Categories of data stored in 'config':
*
* original: what the catalog has
* default: what should be shown as a placeholder if blank
* initial: what current should be initalized as (not to show a placeholder)
*
* local.default: as above, but locally computed (not remembered)
* current: what is being edited (ng model)
*
* Subfields:
* {name,descriptiopn,version,bundle,symbolicName,itemType,iconUrl}
*
* Of the above, `local` and `current` are cleared each time the modal runs.
* If the modal saves, then `initial` is replaced with the value of `current` so it is available next time.
*/
// TODO We might need to refactor the controller and directive around this to structure it better
function updateBundleConfig(entity, metadata, config) {
// the name or can be inherited if root node is a known application type we are editing
// (normally in those cases $scope.config will already be set by caller, but maybe not always)
config.current.name = config.current.name || config.initial.name || config.local.default.name || entity.name
// if user clears the current name, the placeholder could show the default or entity name
// (rather than the last saved name); BUT this is subtle, maybe too much so;
// also we don't support default values on name in the UI, nor in bundlizing, so actually better
// not to set this
// config.local.default.name = config.local.default.name || entity.name || config.original.name;
config.current.version = config.initial.version || config.local.default.version || entity.version || metadata.get('version');
// we do NOT set symbolic name from any entity metadata anymore; that ID is mostly used _internally_ eg might be 'root',
// so it is not necessarily an appropriate symbolic name for the item in the bundle.
//
// if we DID want to do this then we would need to change bundlize to have the same logic
// (or put this logic there instead)
//
// normally it make it a little bit simpler we just set the _current_ for symbolicName and bundle,
// or we allow it to use bundlize to infer from the name
}
function initOurConfig($scope) {
$scope.config.original = $scope.config.original || {}
$scope.config.initial = $scope.config.initial || {}
$scope.config.default = $scope.config.default || {}
$scope.config.current = {};
$scope.config.local = { default: {} };
Object.assign($scope.config.local.default, $scope.config.default);
Object.assign($scope.config.current, $scope.config.initial);
$scope.isNewFromTemplate = () => ($scope.config.initial.itemType !== 'template' && $scope.config.original.itemType === 'template');
$scope.isUpdate = () => !$scope.isNewFromTemplate() && Object.keys($scope.config.original).length>0 && $scope.config.initial.bundle === $scope.config.original.bundle;
$scope.buttonTextFn = () => {
const name = $scope.config.label || ($scope.isUpdate() && ($scope.config.initial.name || $scope.config.original.name || $scope.config.initial.symbolicName || $scope.config.original.symbolicName));
return !!name ? 'Update ' + name : 'Add to catalog';
}
$scope.buttonText = $scope.buttonTextFn();
}
function link($scope, $element) {
initOurConfig($scope);
$scope.activateModal = () => {
let entity = blueprintService.get();
let metadata = blueprintService.entityHasMetadata(entity) ? blueprintService.getEntityMetadata(entity) : new Map();
initOurConfig($scope);
if (!$scope.config.current.itemType) {
// This is the default item type
$scope.config.current.itemType = $scope.config.local.default.itemType || 'application';
}
// Set various properties from the blueprint entity data if not already set
if (!$scope.config.current.iconUrl && ($scope.config.initial.iconUrl || entity.hasIcon() || metadata.has('iconUrl'))) {
$scope.config.current.iconUrl = $scope.config.initial.iconUrl || entity.icon || metadata.get('iconUrl');
}
if (!$scope.isNewFromTemplate()) {
// (these should only be set if not making something new from a template, as the entity items will refer to the template)
(composerOverrides.updateBundleConfig || updateBundleConfig)(entity,metadata, $scope.config);
}
// Override this callback to update configuration data elsewhere
$scope.config = (composerOverrides.updateCatalogConfig || ((config, $element) => config))($scope.config, $element);
const { bundle, symbolicName } = ($scope.config.initial || {});
// Show advanced tab initially if bundle or symbolic name does not match the naming pattern.
$scope.showAdvanced = (bundle && symbolicName)
? !VALID_FIELD_PATTERN.test(bundle) || !VALID_FIELD_PATTERN.test(symbolicName)
: false;
let modalInstance = $uibModal.open({
templateUrl: TEMPLATE_MODAL_URL,
size: 'save',
controller: ['$scope', '$filter', 'blueprintService', 'paletteApi', 'brUtilsGeneral', CatalogItemModalController],
scope: $scope,
});
// Promise is resolved when the modal is closed. We expect the modal to pass back the action to perform thereafter
modalInstance.result.then(reason => {
switch (reason) {
case REASONS.new:
$rootScope.$broadcast('blueprint.reset');
break;
case REASONS.deploy:
$rootScope.$broadcast('blueprint.deploy');
break;
case REASONS.continue:
$rootScope.$broadcast('blueprint.continue');
break;
}
});
};
}
}
export function CatalogItemModalController($scope, $filter, blueprintService, paletteApi, brUtilsGeneral) {
$scope.REASONS = REASONS;
$scope.VIEWS = VIEWS;
$scope.TYPES = TYPES;
$scope.state = {
pattern: VALID_FIELD_PATTERN,
view: VIEWS.form,
saving: false,
force: false
};
/* Derived properties & calculators, will be updated whenever $scope.state.view changes */
$scope.getTitle = () => {
// expect we should always have current or default name, possibly don't need symbolicName or `blueprint` defaults
const name = $scope.config.current.name || $scope.config.local.default.name || $scope.config.current.symbolicName || $scope.config.local.default.symbolicName;
switch ($scope.state.view) {
case VIEWS.form:
return $scope.isUpdate() ? `Update ${name || 'blueprint'}` : 'Add to catalog';
case VIEWS.saved:
return `${name || 'Blueprint'} ${$scope.isUpdate() ? 'updated' : 'saved'}`;
}
};
$scope.getCatalogURL = () => {
const urlPartVersion = _.get($scope, 'config.current.version') || _.get($scope, 'config.version');
if (!urlPartVersion) return "";
switch ($scope.state.view) {
case VIEWS.form:
return '';
case VIEWS.saved:
// TODO where do these come from
return `/brooklyn-ui-catalog/#!/bundles/${$scope.config.catalogBundleId}/${urlPartVersion}/types/${$scope.config.catalogBundleSymbolicName}/${urlPartVersion}`;
}
};
$scope.title = $scope.getTitle();
$scope.catalogURL = $scope.getCatalogURL();
$scope.catalogBomPrefix = 'catalog-bom-';
$scope.$watch('state.view', (newValue, oldValue) => {
if (newValue !== oldValue) {
$scope.title = $scope.getTitle();
$scope.catalogURL = $scope.getCatalogURL();
}
});
/* END Derived properties */
const allTypes = [];
const allBundles = [];
// Prepare resources for analysis if this is not an Update request.
if (!$scope.isUpdate()) {
// Get all types and bundles for analysis.
const promiseTypes = paletteApi.getTypes({params: {versions: 'all'}}).then(data => {
allTypes.push(...data);
}).catch(error => {
$scope.state.error = error;
});
const promiseBundles = paletteApi.getBundles({params: {versions: 'all', detail: true}}).then(data => {
allBundles.push(...data);
}).catch(error => {
$scope.state.error = error;
});
function checkIfBundleExists() {
const bundleName = getBundleId();
if (allBundles.find(item => item.symbolicName === bundleName)) {
$scope.showAdvanced = true;
$scope.state.warning = `Bundle with name "${bundleName}" exists already.`;
} else {
$scope.state.warning = undefined;
}
}
Promise.all([promiseTypes, promiseBundles]).then(() => {
console.info(`Loaded ${allBundles.length} bundles and ${allTypes.length} types for analysis.`)
// Trigger an initial bundle name check.
checkIfBundleExists();
});
// Watch for bundle name and display warning if bundle exists already.
$scope.$watchGroup(['config.current.bundle', 'config.local.default.bundle'], () => {
checkIfBundleExists();
});
}
$scope.save = () => {
$scope.state.saving = true;
$scope.state.error = undefined;
// Analyse existing catalog bundles if this is not an Update request.
if (!$scope.isUpdate()) {
const thisBundle = getBundleId();
const bundles = [];
const uniqueBundlesIds = new Set();
// Check if type exists in other bundles.
bundles.push(...allTypes.filter(item => item.symbolicName === getSymbolicName()).map(item => item.containingBundle));
bundles.forEach(item => uniqueBundlesIds.add(item.split(':')[0]));
if (uniqueBundlesIds.size > 0 && !uniqueBundlesIds.has(thisBundle)) {
$scope.state.error = `This type cannot be saved in bundle "${thisBundle}" from the composer because ` +
`it would conflict with a type with the same ID "${getSymbolicName()}" in ${bundles.map(item => `"${item}"`).join(', ')}.`;
$scope.showAdvanced = true;
$scope.state.saving = false;
return; // DO NOT SAVE!
}
// Check if any of existing bundles include other types.
if (uniqueBundlesIds.size) {
const bundlesWithMultipleTypes = bundles.filter(bundle => {
const [bundleName, bundleVersion] = bundle.split(':');
if (bundleName !== thisBundle) {
return false;
}
const existingBundle = allBundles.find(item => item.symbolicName === bundleName && item.version === bundleVersion);
const otherTypes = existingBundle.types.filter(item => item.symbolicName !== getSymbolicName())
return otherTypes.length > 0;
});
if (!$scope.state.error && bundlesWithMultipleTypes.length) {
$scope.state.error = `This type cannot be saved in bundle "${thisBundle}" from the composer because ` +
`${bundlesWithMultipleTypes.map(item => `"${item}"`).join(', ')} include${bundlesWithMultipleTypes.length > 1 ? '' : 's'} other types.`;
$scope.showAdvanced = true;
$scope.state.saving = false;
return; // DO NOT SAVE!
}
}
}
// Now, try to save.
let bom = createBom();
$scope.config.initial = $scope.config.current;
paletteApi.create(bom, {forceUpdate: $scope.state.force})
.then((savedItem) => {
if (!angular.isArray($scope.config.versions)) {
$scope.config.versions = [];
}
$scope.config.versions.push($scope.config.current.version);
$scope.state.view = VIEWS.saved;
})
.catch(error => {
$scope.state.error = error.error.message;
})
.finally(() => {
$scope.state.saving = false;
});
};
function getBundleBase() {
return $scope.config.current.bundle || $scope.config.local.default.bundle;
}
function getBundleId() {
return getBundleBase() && $scope.catalogBomPrefix + getBundleBase();
}
function getSymbolicName() {
return $scope.config.current.symbolicName || $scope.config.local.default.symbolicName;
}
function createBom() {
let blueprint = blueprintService.getAsJson();
const bundleBase = getBundleBase();
const bundleSymbolicName = getSymbolicName();
if (!bundleBase || !bundleSymbolicName) {
throw "Either the display name must be set, or the bundle and symbolic name must be explicitly set";
}
let bomItem = {
id: bundleSymbolicName,
itemType: $scope.config.current.itemType,
item: blueprint
};
// tags can now be added to a blueprint created in the YAML Editor
let tags = [];
if (blueprint.tags) {
tags = tags.concat(blueprint.tags);
delete blueprint['tags'];
}
if (blueprint['brooklyn.tags']) {
tags = [].concat(blueprint['brooklyn.tags']).concat(tags);
}
blueprint['brooklyn.tags'] = tags;
const bundleId = getBundleId();
let bomCatalogYaml = {
bundle: bundleId,
version: $scope.config.current.version,
items: [ bomItem ]
};
if(tags) {
bomCatalogYaml.tags = tags
}
let bundleName = $scope.config.current.name || $scope.config.local.default.name;
if (brUtilsGeneral.isNonEmpty(bundleName)) {
bomItem.name = bundleName;
}
if (brUtilsGeneral.isNonEmpty($scope.config.current.description)) {
bomItem.description = $scope.config.current.description;
}
if (brUtilsGeneral.isNonEmpty($scope.config.current.iconUrl)) {
bomItem.iconUrl = $scope.config.current.iconUrl;
}
$scope.config.catalogBundleId = bundleId;
$scope.config.catalogBundleSymbolicName = bundleSymbolicName;
return jsYaml.dump({ 'brooklyn.catalog': bomCatalogYaml });
}
let bundlize = $filter('bundlize');
$scope.updateDefaults = (newName) => {
if (!newName) newName = $scope.config.local.default.name;
$scope.config.local.default.symbolicName = $scope.config.default.symbolicName || ($scope.config.current.itemType==='template' && $scope.config.original.symbolicName) || bundlize(newName) || null;
$scope.config.local.default.bundle = $scope.config.default.bundle || ($scope.config.current.itemType==='template' && $scope.config.original.bundle) || bundlize(newName) || null;
};
$scope.$watchGroup(['config.current.name', 'config.current.itemType', 'config.current.bundle', 'config.current.symbolicName'], (newVals) => {
$scope.updateDefaults(newVals[0]);
$scope.form.name.$validate();
$scope.buttonText = $scope.buttonTextFn();
});
}
function composerBlueprintNameValidatorDirective() {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attr, ngModel) {
ngModel.$validators.composerBlueprintNameValidator = function(modelValue, viewValue) {
scope.updateDefaults(modelValue);
if (!ngModel.$isEmpty(modelValue)) {
// anything set is valid
return true;
}
// if not set, we need a bundle and symbolic name
if (scope.config.current.bundle && scope.config.current.symbolicName) {
return true;
}
// or if we have defaults for bundle and symbolic name we don't need this name
if (scope.config.local.default.bundle && scope.config.local.default.symbolicName) {
return true;
}
return false;
}
},
};
}
export function catalogVersionDirective($parse) {
return {
restrict: 'A',
require: 'ngModel',
link: link
};
function link(scope, elm, attr, ctrl) {
if (!ctrl) {
return;
}
let matches;
let force;
scope.$watch(attr.catalogVersion, value => {
if (matches !== value) {
matches = value;
ctrl.$validate();
}
});
}
}
function templateCache($templateCache) {
$templateCache.put(TEMPLATE_URL, template);
$templateCache.put(TEMPLATE_MODAL_URL, modalTemplate);
}
function bundlizeProvider() {
return (input) => input && input.split(/[^a-zA-Z0-9]+/).filter(x => x).join('-').toLowerCase();
}