ui-modules/blueprint-composer/app/components/catalog-selector/catalog-selector.directive.js (371 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 {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']; const SESSION_KEYS = { QUERY: 'composerSearch', VIEW_MODE: 'composerViewMode', } 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 by 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 } $scope.viewModeChange = function(viewMode) { $scope.state.viewMode = viewMode; sessionStorage.setItem(SESSION_KEYS.VIEW_MODE, JSON.stringify(viewMode)); }; } 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) { let savedViewMode; try { savedViewMode = JSON.parse(sessionStorage.getItem(SESSION_KEYS.VIEW_MODE)); } catch(err) { savedViewMode = null; } $scope.state.viewMode = ((typeof savedViewMode === 'object') && (savedViewMode !== null)) ? savedViewMode : PALETTE_VIEW_MODES.normal }; if(!$scope.search && !$scope.iconSelects) { // Do not restore search when the icon triggers selection directly or shows popup - `iconSelects == true`. const savedSearch = sessionStorage.getItem(SESSION_KEYS.QUERY); if (typeof savedSearch === 'string' && savedSearch.length) $scope.search = savedSearch; } $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', (newValue) => { $scope.freeFormTile = { symbolicName: $scope.search, name: $scope.search, id: $scope.search, displayName: $scope.search, supertypes: ($scope.family ? [ $scope.family.superType ] : []), }; if (typeof newValue === 'string') sessionStorage.setItem(SESSION_KEYS.QUERY, newValue); }); $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; } if ($event) $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 (!Array.isArray(tags) || !tags.length) return tags; return tags.filter(tag => !(/[=:\[\]()]/.exec(tag))); } $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 ] // add display name=symbolicName for those that don't have one, to avoid issues with the 'Name' property sorting items.forEach(item => { if (item.tags) { item.displayTags = getDisplayTags(item.tags); } if(!item.displayName) { item.displayName = item.symbolicName; } }); $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 palette = angular.element(document.querySelector(".page-main-area")); let toolbar = angular.element(document.querySelector(".navbar-mode")); let title = 56; 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 - (toolbar[0].offsetHeight + title + header[0].offsetHeight + footer[0].offsetHeight + 32)) / ($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); }