UNPKG

gethue

Version:

Hue is an Open source SQL Query Editor for Databases/Warehouses

603 lines (551 loc) 20.4 kB
// Licensed to Cloudera, Inc. under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. Cloudera, Inc. 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 $ from 'jquery'; import * as ko from 'knockout'; import apiHelper from '../../api/apiHelper'; import dataCatalog from '../../catalog/dataCatalog'; import componentUtils from './componentUtils'; import huePubSub from '../../utils/huePubSub'; import I18n from '../../utils/i18n'; import { ASSIST_SHOW_SQL_EVENT } from './assist/events'; export const NAME = 'hue-global-search'; const TEMPLATE = ` <script type="text/html" id="top-search-autocomp-item"> <a href="javascript:void(0);"> <div> <div><i class="fa fa-fw" data-bind="css: icon"></i></div> <div> <div data-bind="html: label, style: { 'padding-top': description ? 0 : '9px' }"></div> <!-- ko if: description --> <div data-bind="html: description"></div> <!-- /ko --> </div> </div> </a> </script> <script type="text/html" id="top-search-autocomp-no-match"> <div style="height: 30px;"> <div>${I18n('No match found')}</div> </div> </script> <!-- ko component: inlineAutocompleteComponent --><!-- /ko --> <!-- ko if: searchResultVisible() --> <!-- ko if: !loading() && searchResultCategories().length === 0 && hasLoadedOnce() --> <div class="global-search-results global-search-empty" data-bind="onClickOutside: onResultClickOutside"> <div>${I18n('No results found.')}</div> </div> <!-- /ko --> <!-- ko if: searchResultCategories().length > 0 --> <div class="global-search-results" data-bind="onClickOutside: onResultClickOutside, style: { 'height' : heightWhenDragging }"> <div class="global-search-alternatives" data-bind="css: { 'global-search-full-width': !selectedResult() }, delayedOverflow" style="position: relative"> <!-- ko foreach: searchResultCategories --> <div class="global-search-category"> <div class="global-search-category-header" data-bind="text: label"></div> <ul> <!-- ko foreach: expanded() ? result : topMatches --> <li class="result" data-bind="multiClick: { click: function () { $parents[1].resultSelected($parentContext.$index(), $index()) }, dblClick: function () { $parents[1].resultSelected($parentContext.$index(), $index()); $parents[1].openResult(); } }, html: label, css: { 'selected': $parents[1].selectedResult() === $data }"></li> <!-- /ko --> <!-- ko if: topMatches.length < result.length && !expanded() --> <li class="blue" data-bind="toggle: expanded">${I18n('Show more...')}</li> <!-- /ko --> </ul> </div> <!-- /ko --> <!-- ko hueSpinner: { spin: loading() && searchResultCategories().length > 0, inline: true } --><!-- /ko --> </div> <!-- ko with: selectedResult --> <div class="global-search-preview" style="overflow: auto;"> <div class="global-search-close-preview"><a class="pointer inactive-action" data-bind="click: function () { $parent.selectedIndex(undefined); }"><i class="fa fa-fw fa-times"></i></a></div> <!-- ko switch: type --> <!-- ko case: ['database', 'document', 'field', 'table', 'view', 'partition'] --> <!-- ko component: { name: 'context-popover-contents-global-search', params: { data: data, globalSearch: $parent } } --><!-- /ko --> <!-- /ko --> <!-- ko case: $default --> <pre data-bind="text: ko.mapping.toJSON($data)"></pre> <!-- /ko --> <!-- /ko --> </div> <!--/ko --> </div> <!-- /ko --> <!-- /ko --> `; const HUE_DOC_CATEGORY = 'documents'; const NAV_CATEGORY = 'nav'; const CATEGORIES = { table: I18n('Tables'), database: I18n('Databases'), field: I18n('Columns'), partition: I18n('Partitions'), view: I18n('Views'), hueDoc: I18n('Documents') }; class GlobalSearch { constructor() { const self = this; self.knownFacetValues = ko.observable({ type: { field: -1, table: -1, view: -1, database: -1, partition: -1, document: -1 } }); self.cancellablePromises = []; self.autocompleteThrottle = -1; self.fetchThrottle = -1; self.searchHasFocus = ko.observable(false); self.facetDropDownVisible = ko.observable(false); self.querySpec = ko.observable(); self.searchActive = ko.observable(false); self.searchResultVisible = ko.observable(false); self.heightWhenDragging = ko.observable(null); self.searchResultCategories = ko.observableArray([]); self.selectedIndex = ko.observable(); self.loadingNav = ko.observable(false); self.loadingDocs = ko.observable(false); self.loading = ko.pureComputed(() => { return self.loadingNav() || self.loadingDocs(); }); self.hasLoadedOnce = ko.observable(false); self.initializeFacetValues(); self.inlineAutocompleteComponent = { name: 'inline-autocomplete', params: { hasFocus: self.searchHasFocus, disableNavigation: true, showMagnify: true, facetDropDownVisible: self.facetDropDownVisible, spin: self.loading, placeHolder: I18n( window.HAS_CATALOG ? 'Search data and saved documents...' : 'Search saved documents...' ), querySpec: self.querySpec, onClear: function () { self.selectedIndex(null); self.searchResultVisible(false); }, facets: window.HAS_READ_ONLY_CATALOG ? ['classification', 'tag', 'tags', 'type'] : ['originalName', 'parentPath', 'tag', 'tags', 'type'], knownFacetValues: self.knownFacetValues, autocompleteFromEntries: self.autocompleteFromEntries, triggerObservable: self.searchResultCategories } }; self.selectedResult = ko .pureComputed(() => { if (self.selectedIndex()) { return self.searchResultCategories()[self.selectedIndex().categoryIndex].result[ self.selectedIndex().resultIndex ]; } }) .extend({ deferred: true }); const deferredCloseIfVisible = function () { window.setTimeout(() => { if (self.searchResultVisible()) { self.close(); } }, 0); }; huePubSub.subscribe('global.search.close', deferredCloseIfVisible); huePubSub.subscribe('context.popover.open.in.metastore', deferredCloseIfVisible); huePubSub.subscribe('context.popover.show.in.assist', deferredCloseIfVisible); huePubSub.subscribe('sample.error.insert.click', deferredCloseIfVisible); self.querySpec.subscribe(newValue => { window.clearTimeout(self.fetchThrottle); if (newValue && newValue.query !== '') { self.fetchThrottle = window.setTimeout(() => { self.fetchResults(newValue); }, 600); } else { self.searchResultVisible(false); self.selectedIndex(undefined); self.searchResultCategories([]); self.cancelRunningRequests(); } }); self.autocompleteFromEntries = function (lastNonPartial, partial) { let result = undefined; const partialLower = partial.toLowerCase(); self.searchResultCategories().every(category => { return category.result.every(entry => { if ( category.type === 'documents' && entry.data.originalName.toLowerCase().indexOf(partialLower) === 0 ) { result = lastNonPartial + partial + entry.data.originalName.substring(partial.length, entry.data.originalName.length); return false; } else if ( entry.data.selectionName && entry.data.selectionName.toLowerCase().indexOf(partialLower) === 0 ) { result = lastNonPartial + partial + entry.data.selectionName.substring(partial.length, entry.data.selectionName.length); return false; } return true; }); }); return result; }; self.searchHasFocus.subscribe(newVal => { if (newVal && self.querySpec() && self.querySpec().query !== '') { if (!self.searchResultVisible()) { self.searchResultVisible(true); } } }); self.searchResultVisible.subscribe(newVal => { if (newVal) { self.heightWhenDragging(null); } else { self.selectedIndex(undefined); } }); // TODO: Consider attach/detach on focus $(document).keydown(event => { if (self.facetDropDownVisible() || (!self.searchHasFocus() && !self.searchResultVisible())) { return; } if ( event.keyCode === 13 && self.searchHasFocus() && self.querySpec() && self.querySpec().query !== '' ) { window.clearTimeout(self.fetchThrottle); self.fetchResults(self.querySpec()); return; } if (self.searchResultVisible() && self.searchResultCategories().length > 0) { const currentIndex = self.selectedIndex(); if (event.keyCode === 40) { // Down self.searchHasFocus(false); if ( currentIndex && !( self.searchResultCategories()[currentIndex.categoryIndex].result.length <= currentIndex.resultIndex + 1 && self.searchResultCategories().length <= currentIndex.categoryIndex + 1 ) ) { if ( self.searchResultCategories()[currentIndex.categoryIndex].result.length <= currentIndex.resultIndex + 1 ) { self.selectedIndex({ categoryIndex: currentIndex.categoryIndex + 1, resultIndex: 0 }); } else { self.selectedIndex({ categoryIndex: currentIndex.categoryIndex, resultIndex: currentIndex.resultIndex + 1 }); } } else { self.selectedIndex({ categoryIndex: 0, resultIndex: 0 }); } event.preventDefault(); } else if (event.keyCode === 38) { // Up self.searchHasFocus(false); if ( currentIndex && !(currentIndex.categoryIndex === 0 && currentIndex.resultIndex === 0) ) { if (currentIndex.resultIndex === 0) { self.selectedIndex({ categoryIndex: currentIndex.categoryIndex - 1, resultIndex: self.searchResultCategories()[currentIndex.categoryIndex - 1].result.length - 1 }); } else { self.selectedIndex({ categoryIndex: currentIndex.categoryIndex, resultIndex: currentIndex.resultIndex - 1 }); } } else { self.selectedIndex({ categoryIndex: self.searchResultCategories().length - 1, resultIndex: self.searchResultCategories()[self.searchResultCategories().length - 1].result .length - 1 }); } event.preventDefault(); } else if (event.keyCode === 13 && !self.searchHasFocus() && self.selectedIndex()) { // Enter self.openResult(); } } }); } async initializeFacetValues() { try { const facets = await dataCatalog.getAllNavigatorTags({ silenceErrors: true }); const facetValues = self.knownFacetValues(); facetValues['tags'] = facets; facetValues['tag'] = facets; if (window.HAS_READ_ONLY_CATALOG) { facetValues['classification'] = facets; } } catch (err) {} } cancelRunningRequests() { const self = this; while (self.cancellablePromises.length) { const promise = self.cancellablePromises.pop(); if (promise.cancel) { promise.cancel(); } } } close() { const self = this; window.clearTimeout(self.fetchThrottle); self.cancelRunningRequests(); self.searchResultVisible(false); self.hasLoadedOnce(false); self.querySpec({ query: '', facets: {}, text: [] }); } openResult() { const self = this; const selectedResult = self.selectedResult(); if (['database', 'table', 'field', 'view'].indexOf(selectedResult.type) !== -1) { huePubSub.publish('context.popover.show.in.assist'); } else if (selectedResult.type === 'document') { if (selectedResult.data.doc_type === 'directory') { huePubSub.publish('context.popover.show.in.assist'); } else { huePubSub.publish('open.link', '/hue' + selectedResult.data.link); } } self.close(); } resultSelected(categoryIndex, resultIndex) { const self = this; if ( !self.selectedIndex() || !( self.selectedIndex().categoryIndex === categoryIndex && self.selectedIndex().resultIndex === resultIndex ) ) { self.selectedIndex({ categoryIndex: categoryIndex, resultIndex: resultIndex }); } } onResultClickOutside() { const self = this; if (!self.searchResultVisible() || self.searchHasFocus()) { return false; } self.searchResultVisible(false); window.clearTimeout(self.fetchThrottle); window.clearTimeout(self.autocompleteThrottle); } mainSearchSelect(entry) { if (entry.data && entry.data.link) { huePubSub.publish('open.link', entry.data.link); } else if (!/:\s*$/.test(entry.value)) { huePubSub.publish(ASSIST_SHOW_SQL_EVENT); huePubSub.publish('assist.db.search', entry.value); } } updateCategories(type, categoriesToAdd) { const self = this; let newCategories = self.searchResultCategories().filter(category => { return category.type !== type; }); let change = newCategories.length !== self.searchResultCategories().length; if (categoriesToAdd.length > 0) { newCategories = newCategories.concat(categoriesToAdd); newCategories.sort((a, b) => { if (a.weight === b.weight) { return a.label.localeCompare(b.label); } return b.weight - a.weight; }); change = true; } if (change) { const selected = self.selectedResult(); let newIndex = undefined; if (selected) { for (let i = 0; i < newCategories.length; i++) { for (let j = 0; j < newCategories[i].result.length; j++) { if ( newCategories[i].result[j].type === selected.type && newCategories[i].result[j].label === selected.label ) { newIndex = { categoryIndex: i, resultIndex: j }; break; } } if (newIndex) { break; } } } self.selectedIndex(newIndex); self.searchResultCategories(newCategories); } } fetchResults(querySpec) { const self = this; self.cancelRunningRequests(); if (/:$/.test(querySpec.query)) { self.searchResultVisible(false); return; } self.loadingDocs(true); self.searchResultVisible(true); const clearDocsTimeout = window.setTimeout(() => { self.updateCategories(HUE_DOC_CATEGORY, []); }, 300); let docQuery = querySpec.query; const docOnly = querySpec.facets && querySpec.facets['type'] && querySpec.facets['type']['document']; if (docOnly) { docQuery = querySpec.text.join(' '); } self.cancellablePromises.push( apiHelper .fetchHueDocsInteractive(docQuery) .always(() => { self.loadingDocs(false); window.clearTimeout(clearDocsTimeout); }) .done(data => { self.hasLoadedOnce(true); const categories = []; const docCategory = { label: CATEGORIES.hueDoc, result: [], expanded: ko.observable(false), type: HUE_DOC_CATEGORY, weight: 3 }; data.results.forEach(doc => { if (doc.hue_name.indexOf('.') !== 0) { docCategory.result.push({ label: doc.hue_name, type: 'document', data: doc }); } }); if (docCategory.result.length) { docCategory.topMatches = docCategory.result.slice(0, 6); categories.push(docCategory); } self.updateCategories(HUE_DOC_CATEGORY, categories); }) ); if (!docOnly && window.HAS_CATALOG) { const clearNavTimeout = window.setTimeout(() => { self.updateCategories(NAV_CATEGORY, []); }, 300); let navQuery = querySpec.query; // Add * in front of each term unless already there querySpec.text.forEach(textPart => { if ( textPart.indexOf('*') === -1 && navQuery.indexOf('*' + textPart) === -1 && textPart.indexOf(':') === -1 ) { navQuery = navQuery.replace(textPart, '*' + textPart); } }); self.loadingNav(true); self.cancellablePromises.push( apiHelper .fetchNavEntitiesInteractive({ query: navQuery }) .always(() => { self.loadingNav(false); window.clearTimeout(clearNavTimeout); }) .done(data => { self.hasLoadedOnce(true); const categories = []; const newCategories = {}; data.results.forEach(result => { const typeLower = result.type.toLowerCase(); if (CATEGORIES[typeLower]) { let category = newCategories[typeLower]; if (!category) { category = { label: CATEGORIES[typeLower], result: [], expanded: ko.observable(false), type: NAV_CATEGORY, weight: 2 }; newCategories[typeLower] = category; } const meta = { source: 'globalSearch' }; if (result.type.toLowerCase() === 'database') { meta.type = 'sql'; meta.database = result.originalName; } else if (result.type.toLowerCase() === 'table') { meta.type = 'sql'; const split = result.originalName.split('.'); if (split.length === 2) { meta.database = split[0]; meta.table = split[1]; } } else if (result.type.toLowerCase() === 'field') { meta.type = 'sql'; const split = result.originalName.split('.'); if (split.length >= 3) { meta.database = split[0]; meta.table = split[1]; meta.column = split[2]; } } category.result.push({ label: result.hue_name || result.originalName, type: typeLower, data: result }); } }); Object.keys(newCategories).forEach(key => { const category = newCategories[key]; if (category.result.length) { category.topMatches = category.result.slice(0, 6); categories.push(category); } }); self.updateCategories(NAV_CATEGORY, categories); }) ); } else { self.updateCategories(NAV_CATEGORY, []); } } } componentUtils.registerComponent(NAME, GlobalSearch, TEMPLATE);