Selaa lähdekoodia

[editor] Add autocomplete details panel Vue components for set options, UDFs and SQL entities

Johan Ahlen 5 vuotta sitten
vanhempi
commit
59a7a9ae30

+ 123 - 88
desktop/core/src/desktop/js/apps/notebook2/components/aceEditor/autocomplete/AceAutocomplete.vue

@@ -34,7 +34,7 @@
               'border-color': activeCategory === category ? category.color : 'transparent'
             }"
             :class="{ active: activeCategory === category }"
-            @click="categoryClick(category, $event)"
+            @click="clickCategory(category, $event)"
           >
             {{ category.label }}
           </div>
@@ -50,7 +50,7 @@
             :key="activeCategory.categoryId + suggestion.category.categoryId + suggestion.value"
             class="autocompleter-suggestion"
             :class="{ selected: index === selectedIndex }"
-            @click="clickToInsert(index)"
+            @click="clickSuggestion(index)"
             @mouseover="hoveredIndex = index"
             @mouseout="hoveredIndex = null"
           >
@@ -70,34 +70,47 @@
         </div>
       </div>
     </div>
+    <div v-if="detailsComponent" class="autocompleter-details">
+      <component :is="detailsComponent" :suggestion="focusedEntry" />
+    </div>
   </div>
 </template>
 
 <script lang="ts">
-  import sqlUtils, { SortOverride } from 'sql/sqlUtils';
-  import huePubSub from 'utils/huePubSub';
-  import { Category, CategoryId, CategoryInfo, extractCategories } from './Category';
-  import MatchedText from './MatchedText.vue';
-  import Executor from 'apps/notebook2/execution/executor';
-  import { clickOutsideDirective } from 'components/directives/clickOutsideDirective';
-  import Spinner from 'components/Spinner.vue';
-  import SubscriptionTracker from 'components/utils/SubscriptionTracker';
   import { Ace } from 'ext/ace';
   import ace from 'ext/aceHelper';
-  import SqlAutocompleter from './SqlAutocompleter';
-  import { defer } from 'utils/hueUtils';
-  import { Prop } from 'vue-property-decorator';
-  import AutocompleteResults, { Suggestion } from './AutocompleteResults';
   import Vue from 'vue';
   import Component from 'vue-class-component';
+  import { Prop } from 'vue-property-decorator';
 
+  import { Category, CategoryId, CategoryInfo, extractCategories } from './Category';
+  import Executor from 'apps/notebook2/execution/executor';
+  import SubscriptionTracker from 'components/utils/SubscriptionTracker';
+  import sqlUtils, { SortOverride } from 'sql/sqlUtils';
+  import huePubSub from 'utils/huePubSub';
+  import { defer } from 'utils/hueUtils';
   import I18n from 'utils/i18n';
 
+  import AutocompleteResults, { Suggestion } from './AutocompleteResults';
+  import CatalogEntryDetailsPanel from './CatalogEntryDetailsPanel.vue';
+  import MatchedText from './MatchedText.vue';
+  import OptionDetailsPanel from './OptionDetailsPanel.vue';
+  import SqlAutocompleter from './SqlAutocompleter';
+  import UdfDetailsPanel from './UdfDetailsPanel.vue';
+  import Spinner from 'components/Spinner.vue';
+  import { clickOutsideDirective } from 'components/directives/clickOutsideDirective';
+
   const aceUtil = <Ace.AceUtil>ace.require('ace/autocomplete/util');
-  const HashHandler: typeof Ace.HashHandler = ace.require('ace/keyboard/hash_handler').HashHandler;
+  const HashHandler = <typeof Ace.HashHandler>ace.require('ace/keyboard/hash_handler').HashHandler;
 
   @Component({
-    components: { MatchedText, Spinner },
+    components: {
+      CatalogEntryDetailsPanel,
+      MatchedText,
+      OptionDetailsPanel,
+      Spinner,
+      UdfDetailsPanel
+    },
     methods: { I18n },
     directives: {
       'click-outside': clickOutsideDirective
@@ -113,29 +126,25 @@
     @Prop({ required: false, default: false })
     temporaryOnly?: boolean;
 
+    active = false;
     filter = '';
     availableCategories = [Category.All];
     activeCategory = Category.All;
-
-    active = false;
     left = 0;
     top = 0;
     selectedIndex: number | null = null;
     hoveredIndex: number | null = null;
     base: Ace.Anchor | null = null;
     sortOverride?: SortOverride | null = null;
-
     autocompleter?: SqlAutocompleter;
     autocompleteResults?: AutocompleteResults;
 
     changeTimeout = -1;
     positionInterval = -1;
     keyboardHandler: Ace.HashHandler | null = null;
-
     changeListener: (() => void) | null = null;
     mousedownListener = this.detach.bind(this);
     mousewheelListener = this.detach.bind(this);
-
     subTracker = new SubscriptionTracker();
 
     created(): void {
@@ -250,35 +259,24 @@
       );
     }
 
-    get loading(): boolean {
-      return (
-        !this.autocompleteResults ||
-        this.autocompleteResults.loadingKeywords ||
-        this.autocompleteResults.loadingFunctions ||
-        this.autocompleteResults.loadingDatabases ||
-        this.autocompleteResults.loadingTables ||
-        this.autocompleteResults.loadingColumns ||
-        this.autocompleteResults.loadingValues ||
-        this.autocompleteResults.loadingPaths ||
-        this.autocompleteResults.loadingJoins ||
-        this.autocompleteResults.loadingJoinConditions ||
-        this.autocompleteResults.loadingAggregateFunctions ||
-        this.autocompleteResults.loadingGroupBys ||
-        this.autocompleteResults.loadingOrderBys ||
-        this.autocompleteResults.loadingFilters ||
-        this.autocompleteResults.loadingPopularTables ||
-        this.autocompleteResults.loadingPopularColumns
-      );
+    destroyed(): void {
+      this.disposeEventHandlers();
+      this.subTracker.dispose();
     }
 
-    updateFilter(): void {
-      if (this.base) {
-        const pos = this.editor.getCursorPosition();
-        this.filter = this.editor.session.getTextRange({
-          start: this.base,
-          end: pos
-        });
+    get detailsComponent(): string | undefined {
+      if (this.focusedEntry && this.focusedEntry.details) {
+        if (this.focusedEntry.hasCatalogEntry) {
+          return 'CatalogEntryDetailsPanel';
+        }
+        if (this.focusedEntry.category.categoryId === CategoryId.UDF) {
+          return 'UdfDetailsPanel';
+        }
+        if (this.focusedEntry.category.categoryId === CategoryId.Option) {
+          return 'OptionDetailsPanel';
+        }
       }
+      return undefined;
     }
 
     get filtered(): Suggestion[] {
@@ -328,6 +326,74 @@
       return result;
     }
 
+    get focusedEntry(): Suggestion | undefined {
+      if (this.filtered.length) {
+        if (this.hoveredIndex !== null) {
+          return this.filtered[this.hoveredIndex];
+        } else if (this.selectedIndex !== null) {
+          return this.filtered[this.selectedIndex];
+        }
+      }
+      return undefined;
+    }
+
+    get loading(): boolean {
+      return (
+        !this.autocompleteResults ||
+        this.autocompleteResults.loadingKeywords ||
+        this.autocompleteResults.loadingFunctions ||
+        this.autocompleteResults.loadingDatabases ||
+        this.autocompleteResults.loadingTables ||
+        this.autocompleteResults.loadingColumns ||
+        this.autocompleteResults.loadingValues ||
+        this.autocompleteResults.loadingPaths ||
+        this.autocompleteResults.loadingJoins ||
+        this.autocompleteResults.loadingJoinConditions ||
+        this.autocompleteResults.loadingAggregateFunctions ||
+        this.autocompleteResults.loadingGroupBys ||
+        this.autocompleteResults.loadingOrderBys ||
+        this.autocompleteResults.loadingFilters ||
+        this.autocompleteResults.loadingPopularTables ||
+        this.autocompleteResults.loadingPopularColumns
+      );
+    }
+
+    get visible(): boolean {
+      return this.active && (this.loading || !!this.filtered.length);
+    }
+
+    clickCategory(category: CategoryInfo, event: Event): void {
+      if (!this.autocompleteResults) {
+        return;
+      }
+      this.activeCategory = category;
+
+      event.stopPropagation();
+      this.editor.focus();
+    }
+
+    clickOutside(): void {
+      if (this.active) {
+        this.detach();
+      }
+    }
+
+    clickSuggestion(index: number): void {
+      this.selectedIndex = index;
+      this.insertSuggestion();
+      this.editor.focus();
+    }
+
+    updateFilter(): void {
+      if (this.base) {
+        const pos = this.editor.getCursorPosition();
+        this.filter = this.editor.session.getTextRange({
+          start: this.base,
+          end: pos
+        });
+      }
+    }
+
     registerKeybindings(keyboardHandler: Ace.HashHandler): void {
       keyboardHandler.bindKeys({
         Up: () => {
@@ -398,12 +464,6 @@
       });
     }
 
-    clickToInsert(index: number): void {
-      this.selectedIndex = index;
-      this.insertSuggestion();
-      this.editor.focus();
-    }
-
     insertSuggestion(emptyCallback?: () => void): void {
       if (this.selectedIndex === null || !this.filtered.length) {
         this.detach();
@@ -446,10 +506,6 @@
       this.left = newPos.left + rect.left - renderer.scrollLeft + renderer.gutterWidth;
     }
 
-    get visible(): boolean {
-      return this.active && (this.loading || !!this.filtered.length);
-    }
-
     scrollSelectionIntoView(): void {
       // TODO: implement
     }
@@ -496,19 +552,19 @@
       }, 300);
     }
 
-    categoryClick(category: CategoryInfo, event: Event): void {
+    detach(): void {
       if (!this.autocompleteResults) {
         return;
       }
-      this.activeCategory = category;
-
-      event.stopPropagation();
-      this.editor.focus();
-    }
-
-    clickOutside(): void {
-      if (this.active) {
-        this.detach();
+      this.autocompleteResults.cancelRequests();
+      this.disposeEventHandlers();
+      if (!this.active) {
+        return;
+      }
+      this.active = false;
+      if (this.base) {
+        this.base.detach();
+        this.base = null;
       }
     }
 
@@ -524,27 +580,6 @@
       this.editor.off('mousedown', this.mousedownListener);
       this.editor.off('mousewheel', this.mousewheelListener);
     }
-
-    detach(): void {
-      if (!this.autocompleteResults) {
-        return;
-      }
-      this.autocompleteResults.cancelRequests();
-      this.disposeEventHandlers();
-      if (!this.active) {
-        return;
-      }
-      this.active = false;
-      if (this.base) {
-        this.base.detach();
-        this.base = null;
-      }
-    }
-
-    destroyed(): void {
-      this.disposeEventHandlers();
-      this.subTracker.dispose();
-    }
   }
 </script>
 

+ 133 - 0
desktop/core/src/desktop/js/apps/notebook2/components/aceEditor/autocomplete/CatalogEntryDetailsPanel.vue

@@ -0,0 +1,133 @@
+<!--
+  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.
+-->
+
+<template>
+  <div>
+    <div class="autocompleter-header">
+      <i
+        class="fa fa-fw"
+        :class="{
+          'fa-database': details.isDatabase(),
+          'fa-eye': details.isView(),
+          'fa-table': details.isTable(),
+          'fa-columns': details.isField()
+        }"
+      />
+      <span>{{ details.getTitle() }}</span>
+      <div
+        v-if="suggestion.popular && suggestion.relativePopularity"
+        class="autocompleter-header-popularity"
+      >
+        <i class="fa fa-fw fa-star-o popular-color" :title="popularityTitle" />
+      </div>
+    </div>
+    <div class="autocompleter-details-contents">
+      <div class="autocompleter-details-contents-inner">
+        <div v-if="details.isColumn()">
+          <div class="details-attribute">
+            <i class="fa fa-table fa-fw" />
+            <span>{{ details.path[0] }}.{{ details.path[1] }}</span>
+          </div>
+        </div>
+        <div v-if="details.isPartitionKey()" class="details-attribute">
+          <i class="fa fa-key fa-fw" /> {{ I18n('Partition key') }}
+        </div>
+        <div v-else-if="details.isPrimaryKey()" class="details-attribute">
+          <i class="fa fa-key fa-fw" /> {{ I18n('Primary key') }}
+        </div>
+        <div v-else-if="details.isForeignKey()" class="details-attribute">
+          <i class="fa fa-key fa-fw" /> {{ I18n('Foreign key') }}
+        </div>
+
+        <div v-if="loading" class="details-comment">
+          <spinner size="small" inline="true" />
+        </div>
+        <div v-else-if="comment" class="details-comment">{{ comment }}</div>
+        <div v-else class="details-no-comment">{{ I18n('No description') }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+  import CancellableJqPromise from 'api/cancellableJqPromise';
+  import Vue from 'vue';
+  import Component from 'vue-class-component';
+  import { Prop } from 'vue-property-decorator';
+
+  import DataCatalogEntry from 'catalog/dataCatalogEntry';
+  import I18n from 'utils/i18n';
+  import { Suggestion } from './AutocompleteResults';
+
+  import Spinner from 'components/Spinner.vue';
+
+  const COMMENT_LOAD_DELAY = 1500;
+
+  @Component({
+    components: { Spinner },
+    methods: { I18n }
+  })
+  export default class CatalogEntryDetailsPanel extends Vue {
+    @Prop({ required: true })
+    suggestion!: Suggestion;
+
+    loading = false;
+    comment: string | null = null;
+    loadTimeout = -1;
+    commentPromise: CancellableJqPromise<string> | null = null;
+
+    mounted(): void {
+      if (this.details.hasResolvedComment()) {
+        this.comment = this.details.getResolvedComment();
+      } else {
+        this.loading = true;
+        this.loadTimeout = window.setTimeout(() => {
+          this.commentPromise = this.details
+            .getComment({ silenceErrors: true, cancellable: true })
+            .done(comment => {
+              this.comment = comment;
+            })
+            .always(() => {
+              this.loading = false;
+            });
+        }, COMMENT_LOAD_DELAY);
+      }
+    }
+
+    destroyed(): void {
+      window.clearTimeout(this.loadTimeout);
+      if (this.commentPromise) {
+        this.commentPromise.cancel();
+      }
+    }
+
+    get details(): DataCatalogEntry {
+      return <DataCatalogEntry>this.suggestion.details;
+    }
+
+    get popularityTitle(): string {
+      return `${I18n('Popularity')} ${this.suggestion.relativePopularity}%`;
+    }
+
+    get showTitle(): boolean {
+      return false;
+    }
+  }
+</script>
+
+<style lang="scss" scoped></style>

+ 57 - 0
desktop/core/src/desktop/js/apps/notebook2/components/aceEditor/autocomplete/OptionDetailsPanel.vue

@@ -0,0 +1,57 @@
+<!--
+  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.
+-->
+
+<template>
+  <div>
+    <div class="autocompleter-header">{{ suggestion.value }}</div>
+    <div class="autocompleter-details-contents">
+      <div class="autocompleter-details-contents-inner">
+        <div class="details-code">
+          {{ I18n('Type:') }} <span>{{ details.type }}</span>
+        </div>
+        <div class="details-code">
+          {{ I18n('Default:') }} <span>{{ details.default }}</span>
+        </div>
+        <div class="details-description">{{ details.description }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+  import { SetDetails } from 'sql/reference/types';
+  import I18n from 'utils/i18n';
+  import { Suggestion } from './AutocompleteResults';
+  import Vue from 'vue';
+  import Component from 'vue-class-component';
+  import { Prop } from 'vue-property-decorator';
+
+  @Component({
+    methods: { I18n }
+  })
+  export default class OptionDetailsPanel extends Vue {
+    @Prop({ required: true })
+    suggestion!: Suggestion;
+
+    get details(): SetDetails {
+      return <SetDetails>this.suggestion.details;
+    }
+  }
+</script>
+
+<style lang="scss" scoped></style>

+ 56 - 0
desktop/core/src/desktop/js/apps/notebook2/components/aceEditor/autocomplete/UdfDetailsPanel.vue

@@ -0,0 +1,56 @@
+<!--
+  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.
+-->
+
+<template>
+  <div>
+    <div class="autocompleter-header">
+      <i class="fa fa-fw fa-superscript" />
+      <span>{{ udfName }}</span>
+    </div>
+    <div class="autocompleter-details-contents">
+      <div class="autocompleter-details-contents-inner">
+        <div class="details-code">{{ details.signature }}</div>
+        <div class="details-description">{{ details.description }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+  import { UdfDetails } from 'sql/reference/types';
+  import { Suggestion } from './AutocompleteResults';
+  import Vue from 'vue';
+  import Component from 'vue-class-component';
+  import { Prop } from 'vue-property-decorator';
+
+  @Component
+  export default class UdfDetailsPanel extends Vue {
+    @Prop({ required: true })
+    suggestion!: Suggestion;
+
+    get details(): UdfDetails {
+      return <UdfDetails>this.suggestion.details;
+    }
+
+    get udfName(): string {
+      return this.details.signature.substring(0, this.details.signature.indexOf('('));
+    }
+  }
+</script>
+
+<style lang="scss" scoped></style>

+ 3 - 0
desktop/core/src/desktop/js/catalog/dataCatalogEntry/index.d.ts

@@ -24,10 +24,12 @@ declare class DataCatalogEntry {
   optimizerPopularity: {
     [attr: string]: DataCatalogEntry.OptimizerPopularity | number;
   };
+  getComment(options: DataCatalogEntry.CatalogGetOptions): CancellableJqPromise<string>;
   getChildren(
     options: DataCatalogEntry.CatalogGetOptions
   ): CancellableJqPromise<DataCatalogEntry[]>;
   getQualifiedPath(): string;
+  getResolvedComment(): string;
   getSourceMeta(
     options: DataCatalogEntry.CatalogGetOptions
   ): CancellableJqPromise<DataCatalogEntry.SourceMeta>;
@@ -41,6 +43,7 @@ declare class DataCatalogEntry {
   getTopFilters(
     options: DataCatalogEntry.CatalogGetOptions
   ): CancellableJqPromise<DataCatalogEntry.TopFilters>;
+  hasResolvedComment(): boolean;
   isArray(): boolean;
   isComplex(): boolean;
   isMap(): boolean;