gethue
Version:
Hue is an Open source SQL Query Editor for Databases/Warehouses
603 lines (551 loc) • 20.4 kB
JavaScript
// 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);