blob: 1513b012f58c6434f1cb5e09eaaa715952ca702f [file] [log] [blame]
/*
* 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 {EntityFamily} from '../util/model/entity.model';
import template from './catalog-selector.template.html';
import footerTemplate from './catalog-selector-palette-footer.html';
import { distanceInWordsToNow } from 'date-fns';
const MODULE_NAME = 'brooklyn.composer.component.catalog-selector';
const TEMPLATE_URL = 'blueprint-composer/component/catalog-selector/index.html';
const TEMPLATE_SUBHEAD_URL = 'blueprint-composer/component/catalog-selector/subhead.html';
const TEMPLATE_FOOTER_URL = 'blueprint-composer/component/catalog-selector/footer.html';
const MIN_ROWS_PER_PAGE = 4;
const PALETTE_VIEW_ORDERS = {
relevance: { id: "relevance", label: "Relevance", field: "relevance" },
name: { id: "name", label: "Name", field: "displayName" },
lastUsed: { id: "lastUsed", label: "Recent", field: "-lastUsed" },
bundle: { id: "bundle", label: "Bundle", field: "containingBundle" },
id: { id: "id", label: "ID", field: "symbolicName" },
};
const PALETTE_VIEW_MODES = {
tiny: { name: "Tiny", classes: "col-xs-2 item-compact", itemsPerRow: 6, rowHeightPx: 75, hideName: true },
compact: { name: "Compact", classes: "col-xs-3", itemsPerRow: 4 },
normal: { name: "Normal", classes: "col-xs-4", itemsPerRow: 3 },
large: { name: "Large", classes: "col-xs-6", itemsPerRow: 2 },
list: { name: "List", classes: "col-xs-12 item-full-width", itemsPerRow: 1 },
compactList: { name: "Compact list", classes: "col-xs-12 item-compact-list", itemsPerRow: 1, rowHeightPx: 30 },
};
// fields in either bundle or type record:
const FIELDS_TO_SEARCH = ['displayName', 'name', 'symbolicName', 'type', 'version', 'containingBundle', 'description', 'displayTags', 'tags', 'supertypes'];
angular.module(MODULE_NAME, [])
.directive('catalogSelector', catalogSelectorDirective)
.filter('catalogSelectorSearch', catalogSelectorSearchFilter)
.filter('catalogSelectorFilters', catalogSelectorFiltersFilter)
.run(['$templateCache', templateCache]);
export default MODULE_NAME;
export function catalogSelectorDirective() {
return {
restrict: 'E',
templateUrl: function (tElement, tAttrs) {
return tAttrs.templateUrl || TEMPLATE_URL;
},
scope: {
family: '<',
onSelect: '&', // action to do when item is selected
onSelectText: "&?", // function returning text to show in the "on select" button for an item
iconSelects: '<?', // boolean whether clicking the icon triggers selection directly or shows popup (false, default)
rowsPerPage: '<?', // optionally show fixed number of rows; unset (default and normal) computes based on available height
reservedKeys: '<?',
state: '<?', // for shared state usage
mode: '@?', // for use by downstream projects to pass in special modes to do add'l processing / rendering
},
controller: ['$scope', '$element', '$timeout', '$q', '$uibModal', '$log', '$templateCache', 'paletteApi', 'paletteDragAndDropService', 'iconGenerator', 'composerOverrides', 'recentlyUsedService', controller],
link: link,
};
function link($scope, $element, attrs, controller) {
let main = angular.element($element[0].querySelector(".catalog-palette-main"));
// repaginate when load completes (and items are shown), or it is resized
$scope.$watchGroup(
[() => $scope.isLoading, () => main[0].offsetHeight, () => $scope.state.viewMode.name],
(values) => controller.$timeout(() => repaginate($scope, $element)));
// also repaginate on window resize
angular.element(window).bind('resize', () => repaginate($scope, $element));
$scope.templateUrls = {
subhead: TEMPLATE_SUBHEAD_URL,
footer: TEMPLATE_FOOTER_URL
}
}
function controller($scope, $element, $timeout, $q, $uibModal, $log, $templateCache, paletteApi, paletteDragAndDropService, iconGenerator, composerOverrides, recentlyUsedService) {
this.$timeout = $timeout;
$scope.viewModes = PALETTE_VIEW_MODES;
$scope.viewOrders = PALETTE_VIEW_ORDERS;
if (!$scope.state) $scope.state = {};
if (!$scope.state.viewMode) $scope.state.viewMode = PALETTE_VIEW_MODES.normal;
$scope.pagination = {
page: 1,
itemsPerPage: $scope.state.viewMode.itemsPerRow * ($scope.rowsPerPage || 1) // will fill out after load
};
$scope.getEntityNameForPalette = function(item, entityName) {
return (composerOverrides.getEntityNameForPalette ||
// above can be overridden with function of signature below to customize display name in palette
function(item, entityName, scope) { return entityName; }
)(item, entityName, $scope);
};
$scope.getPlaceHolder = function () {
return 'Search';
};
$scope.isLoading = true;
$scope.$watch('search', () => {
$scope.freeFormTile = {
symbolicName: $scope.search,
name: $scope.search,
displayName: $scope.search,
supertypes: ($scope.family ? [ $scope.family.superType ] : []),
};
});
$scope.getItems = function (search) {
let defer = $q.resolve([]);
switch ($scope.family) {
case EntityFamily.ENTITY:
case EntityFamily.SPEC:
defer = paletteApi.getTypes({params: {supertype: 'entity', fragment: search}});
break;
case EntityFamily.POLICY:
defer = paletteApi.getTypes({params: {supertype: 'policy', fragment: search}});
break;
case EntityFamily.ENRICHER:
defer = paletteApi.getTypes({params: {supertype: 'enricher', fragment: search}});
break;
case EntityFamily.LOCATION:
defer = paletteApi.getLocations();
break;
}
return defer.then(data => {
data = $scope.filterPaletteItemsForMode(data, $scope);
data.forEach( recentlyUsedService.embellish );
return data;
}).catch(error => {
return [];
}).finally(() => {
$scope.isLoading = false;
});
};
function tryMarkUsed(item) {
try {
recentlyUsedService.markUsed(item);
} catch (e) {
// session storage can get full; usually the culprit is icons not this,
// but we may wish to clear out old items to ensure we don't bleed here
$log.warn("Could not mark item as used: "+item, e);
}
}
$scope.mouseInfoPopover = (item, enter) => {
if ($scope.popoverModal && $scope.popoverVisible && $scope.popover==item) {
// ignore if modal
return;
}
$scope.popoverModal = false;
if (enter) {
$scope.popover = item;
$scope.popoverVisible = true;
} else {
$scope.popoverVisible = false;
}
};
$scope.onClickItem = (item, isInfoIcon, $event) => {
if (!isInfoIcon && $scope.iconSelects) {
$scope.onSelectItem(item);
} else if ($scope.popoverModal && $scope.popoverVisible && $scope.popover == item) {
$scope.closePopover();
} else {
$scope.popover = item;
$scope.popoverVisible = true;
$scope.popoverModal = true;
}
$event.stopPropagation();
};
$scope.closePopover = () => {
$scope.popoverVisible = false;
$scope.popoverModal = false;
};
$scope.getOnSelectText = function (item) {
if (!($scope.onSelectText)) return "Select";
return $scope.onSelectText({item: item});
};
$scope.onSelectItem = function (item) {
$scope.closePopover();
if (angular.isFunction($scope.onSelect)) {
tryMarkUsed(item);
$scope.onSelect({item: item});
}
$scope.search = '';
};
$scope.onDragItem = function (item, event) {
let frame = document.createElement('div');
frame.classList.add('drag-frame');
event.target.appendChild(frame);
setTimeout(function() {
// can remove at end of this cycle, browser will have grabbed its drag image
frame.parentNode.removeChild(frame);
}, 0);
/* have tried many other ways to get a nice drag image;
this seems to work best, adding an empty div which forces the size to be larger,
so when grabbing the image it grabs the drop-shadow.
things that _didn't_ work include:
- styling event.target now then unstyling (normally this would work, in posts on the web, but it doesn't here; angular?)
- make a restyled cloned copy offscreen (this comes so close but remote img srcs aren't loaded
*/
paletteDragAndDropService.dragStart(item);
};
$scope.onDragEnd = function (item, event) {
paletteDragAndDropService.dragEnd();
tryMarkUsed(item);
};
$scope.getOpenCatalogLink = (item) => {
return "/brooklyn-ui-catalog/#!/bundles/"+item.containingBundle.replace(":","/")+"/types/"+item.symbolicName+"/"+item.version;
};
$scope.sortBy = function (order) {
let newFirst = {};
if (order) {
newFirst[order.id] = order;
}
$scope.state.currentOrder = Object.assign(newFirst, $scope.state.currentOrder, newFirst);
$scope.state.currentOrderFields = [];
$scope.state.currentOrderValues = [];
Object.values($scope.state.currentOrder).forEach( it => {
$scope.state.currentOrderValues.push(it);
$scope.state.currentOrderFields.push(it.field);
});
};
if (!$scope.state.currentOrder) $scope.state.currentOrder = Object.assign({}, PALETTE_VIEW_ORDERS);
$scope.sortBy();
$scope.allowFreeForm = function () {
return [
EntityFamily.LOCATION
].indexOf($scope.family) > -1;
};
$scope.isReserved = function () {
if (!$scope.reservedKeys || !angular.isArray($scope.reservedKeys)) {
return false;
}
return $scope.reservedKeys.indexOf($scope.search) > -1;
};
$scope.onImageError = (scope, el, attrs) => {
$log.warn("Icon for "+attrs.itemId+" at "+angular.element(el).attr("src")+" could not be loaded; generating icon");
angular.element(el).attr("src", iconGenerator(attrs.itemId));
};
// Init
$scope.items = [];
function getDisplayTags(tags) {
if (!tags || !tags.length || !tags.reduce) return tags;
return tags.reduce((result, tag) => {
if (!(/[=:\[\]()]/.exec(tag))) {
result.push(tag);
}
return result;
}, []);
}
$scope.getItems().then((items)=> {
// add displayTags, as any tag that doesn't contain = : or ( ) [ ]
// any tag that is an object will be eliminated as it is toStringed to make [ object object ]
items.forEach(item => {
if (item.tags) {
item.displayTags = getDisplayTags(item.tags);
}
});
$scope.items = items;
});
$scope.lastUsedText = (item) => {
if (item==null) return "";
let l = (Number)(item.lastUsed);
if (!l || isNaN(l) || l<=0) return "";
if (l < 100000) return 'Preselected for inclusion in "Recent" filter.';
return 'Last used: ' + distanceInWordsToNow(l, { includeSeconds: true, addSuffix: true });
};
$scope.showPaletteControls = false;
$scope.onFiltersShown = () => {
$timeout( () => {
// check do we need to show the multiline
let filters = angular.element($element[0].querySelector(".filters"));
$scope.$apply( () => $scope.filterSettings.filtersMultilineAvailable = filters[0].scrollHeight > filters[0].offsetHeight + 6 );
repaginate($scope, $element);
} );
};
$scope.togglePaletteControls = () => {
$scope.showPaletteControls = !$scope.showPaletteControls;
$timeout( () => repaginate($scope, $element) );
};
$scope.toggleShowAllFilters = () => {
$scope.filterSettings.showAllFilters = !$scope.filterSettings.showAllFilters;
$timeout( () => repaginate($scope, $element) );
};
$scope.filterSettings = {};
$scope.filters = [
{ label: 'Recent', icon: 'clock-o', title: "Recently used and standard favorites", limitToOnePage: true,
filterInit: items => {
$scope.recentItems = items.filter( i => i.lastUsed && i.lastUsed>0 );
$scope.recentItems.sort( (a,b) => b.lastUsed - a.lastUsed );
return $scope.recentItems;
}, enabled: false },
];
$scope.disableFilters = (showFilters) => {
$scope.filters.forEach( f => f.enabled = false );
if (showFilters !== false) {
$scope.showPaletteControls = true;
}
};
// can be overridden to disable "open in catalog" button
$scope.allowOpenInCatalog = true;
// this can be overridden for palette sections/modes which show a subset of the types returned by the server;
// this is applied when the data is received from the server.
// it is used by catalogSelectorFiltersFilter;
$scope.filterPaletteItemsForMode = (items) => items;
// allow downstream to configure this controller and/or scope
(composerOverrides.configurePaletteController || function() {})(this, $scope, $element);
}
function repaginate($scope, $element) {
let rowsPerPage = $scope.rowsPerPage;
if (!rowsPerPage) {
let main = angular.element($element[0].querySelector(".catalog-palette-main"));
if (!main || main[0].offsetHeight == 0) {
// no main, or hidden, or items per page fixed
return;
}
let palette = angular.element(document.getElementsByClassName("palette-and-or-toolbar"));
let header = angular.element($element[0].querySelector(".catalog-palette-header"));
let footer = angular.element($element[0].querySelector(".catalog-palette-footer"));
rowsPerPage = Math.max(MIN_ROWS_PER_PAGE, Math.floor((palette[0].offsetHeight - (header[0].offsetHeight + footer[0].offsetHeight + 16)) / ($scope.state.viewMode.rowHeightPx || 96)));
}
$scope.$apply(() => $scope.pagination.itemsPerPage = rowsPerPage * $scope.state.viewMode.itemsPerRow);
}
}
export function catalogSelectorSearchFilter() {
return function (items, search) {
if (search) {
return items.filter(function (item) {
item.relevance = 0;
let wordNum = 0;
return search.toLowerCase().split(' ').reduce( (found, part) => {
wordNum++;
let fieldNum = 0;
return found &&
FIELDS_TO_SEARCH.reduce((match, field) => {
if (match) return true;
fieldNum++;
if (!item.hasOwnProperty(field) || !item[field]) return false;
let text = item[field];
if (!text.toLowerCase) {
text = JSON.stringify(text).toLowerCase();
} else {
text = text.toLowerCase();
}
let index = text.indexOf(part);
if (index == -1) return false;
// found, set relevance -- uses an ad hoc heuristic preferring first fields and short text length,
// earlier occurrences and earlier words weighted more highly (smaller number is better)
let score = fieldNum * (2 / (1 + wordNum)) * Math.log(1 + text.length * index);
/* to debug the scoring function:
if (item.symbolicName.indexOf("EIP") >= 0 || item.symbolicName.indexOf("OpsWorks") >= 0) {
console.log(item.symbolicName, ": match", part, "in", field,
"#", fieldNum, wordNum,
"pos", index, "/", text.length,
":", item.relevance, "+=", score);
}
*/
item.relevance += score;
return true;
}, false)
}, true);
});
} else {
items.forEach( item => item.relevance = 0 );
return items;
}
}
}
export function catalogSelectorFiltersFilter() {
// compute counts and apply active filters;
// this is called by the view after filtering based on search,
// so filters can adjust based on number of search results
return function (items, $scope) {
$scope.itemsBeforeActiveFilters = items;
$scope.skippingFilters = false;
let filters = $scope.filters.filter(f => f.enabled);
let filtersWithFn = filters.filter(f => f.filterFn);
if (!filters.length) {
$scope.itemsAfterActiveFilters = items;
return items;
}
filters.forEach(filter => { if (filter.filterInit) items = filter.filterInit(items); });
if (filtersWithFn.length) {
items = items.filter( item => filtersWithFn.some(filter => filter.filterFn(item)) );
}
if (!items || !items.length) {
// if search matches nothing then disable filters
items = $scope.itemsAfterActiveFilters = $scope.itemsBeforeActiveFilters;
$scope.skippingFilters = true;
} else {
if (filters.find(filter => filter.limitToOnePage)) {
items = items.splice(0, $scope.pagination.itemsPerPage);
}
$scope.itemsAfterActiveFilters = items;
}
return items;
}
}
function templateCache($templateCache) {
$templateCache.put(TEMPLATE_URL, template);
$templateCache.put(TEMPLATE_SUBHEAD_URL, '');
$templateCache.put(TEMPLATE_FOOTER_URL, footerTemplate);
}