Pārlūkot izejas kodu

HUE-8981 [editor] Extract the session authorization modal from the editor view model to a separate component

Johan Ahlen 6 gadi atpakaļ
vecāks
revīzija
a68f442c1f

+ 22 - 16
desktop/core/src/desktop/js/api/apiHelper.js

@@ -335,7 +335,7 @@ class ApiHelper {
    * @returns {Function}
    */
   assistErrorCallback(options) {
-    return function(errorResponse) {
+    return errorResponse => {
       let errorMessage = 'Unknown error occurred';
       if (typeof errorResponse !== 'undefined' && errorResponse !== null) {
         if (
@@ -1878,28 +1878,34 @@ class ApiHelper {
     return this.simplePost('/notebook/api/notebook/close', data);
   }
 
-  createSession(options) {
-    const data = {
-      notebook: options.notebookJson,
-      session: options.sessionJson,
-      cluster: options.clusterJson
-    };
-    return this.simplePost('/notebook/api/create_session', data);
-  }
-
   /**
    * Creates a new session
    *
    * @param {Object} options
    * @param {String} options.type
    * @param {Array<SessionProperty>} [options.properties] - Default []
-   * @return {Promise}
+   * @return {Promise<Session>}
    */
-  createSessionV2(options) {
-    const data = {
-      session: JSON.stringify({ type: options.type, properties: options.properties || [] })
-    };
-    return this.simplePost('/notebook/api/create_session', data, options);
+  async createSession(options) {
+    return new Promise((resolve, reject) => {
+      const data = {
+        session: JSON.stringify({ type: options.type, properties: options.properties || [] })
+      };
+      $.post({
+        url: '/notebook/api/create_session',
+        data: data
+      })
+        .done(data => {
+          if (data.status === 401) {
+            resolve({ auth: true, message: data.message });
+          } else if (this.successResponseIsError(data)) {
+            reject(this.assistErrorCallback(options)(data));
+          } else {
+            resolve(data.session);
+          }
+        })
+        .fail(this.assistErrorCallback(options));
+    });
   }
 
   closeSession(options) {

+ 0 - 6
desktop/core/src/desktop/js/apps/notebook2/editorViewModel.js

@@ -139,12 +139,6 @@ class EditorViewModel {
       $(document).trigger('editingToggled');
     });
 
-    self.authSessionUsername = ko.observable(); // UI popup
-    self.authSessionPassword = ko.observable();
-    self.authSessionMessage = ko.observable();
-    self.authSessionType = ko.observable();
-    self.authSessionCallback = ko.observable();
-
     self.removeSnippetConfirmation = ko.observable();
 
     self.assistAvailable = ko.observable(options.assistAvailable);

+ 58 - 34
desktop/core/src/desktop/js/apps/notebook2/execution/sessionManager.js

@@ -14,21 +14,22 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import apiHelper from '../../../api/apiHelper';
+import apiHelper from 'api/apiHelper';
+import huePubSub from 'utils/huePubSub';
 
 class SessionManager {
   constructor() {
-    this.knownSessions = {};
+    this.knownSessionPromises = {};
   }
   /**
    * @typedef SessionProperty
-   * @property {Array<*>} defaultValue
-   * @property {String} help_text
+   * @property {Array<*>} [defaultValue]
+   * @property {String} [help_text]
    * @property {String} key
-   * @property {Boolean} multiple
-   * @property {String} nice_name
-   * @property {Array<*>} options
-   * @property {String} type
+   * @property {Boolean} [multiple]
+   * @property {String} [nice_name]
+   * @property {Array<*>} [options]
+   * @property {String} [type]
    * @property {Array<*>} value
    */
 
@@ -53,29 +54,57 @@ class SessionManager {
    * @return {Promise<Session>}
    */
   async getSession(options) {
-    if (!this.knownSessions[options.type]) {
-      this.knownSessions[options.type] = new Promise((resolve, reject) => {
-        apiHelper
-          .createSessionV2({
-            type: options.type,
-            properties: options.properties
-          })
-          .then(response => {
-            resolve(response.session);
-          })
-          .fail(reject);
-      });
-
+    if (!this.knownSessionPromises[options.type]) {
+      this.knownSessionPromises[options.type] = this.createDetachedSession(options);
       // Sessions that fail
-      this.knownSessions[options.type].catch(() => {
-        delete this.knownSessions[options.type];
+      this.knownSessionPromises[options.type].catch(() => {
+        delete this.knownSessionPromises[options.type];
       });
     }
-    return this.knownSessions[options.type];
+    return this.knownSessionPromises[options.type];
+  }
+
+  /**
+   * Creates a new detached session
+   *
+   * @param {Object} options
+   * @param {String} options.type
+   * @param {boolean} [options.preventAuthModal] - Default false
+   * @param {Array<SessionProperty>} [options.properties] - Default []
+   *
+   * @return {Promise<Session>}
+   */
+  async createDetachedSession(options) {
+    return new Promise((resolve, reject) => {
+      const sessionToCreate = {
+        type: options.type,
+        properties: options.properties || []
+      };
+      apiHelper
+        .createSession(sessionToCreate)
+        .then(resolve)
+        .catch(reason => {
+          if (reason && reason.auth) {
+            // The auth modal will resolve or reject
+            if (!options.preventAuthModal) {
+              huePubSub.publish('show.session.auth.modal', {
+                message: reason.message,
+                session: sessionToCreate,
+                resolve: resolve,
+                reject: reject
+              });
+            }
+          } else {
+            reject(reason);
+          }
+        });
+    });
   }
 
   async getAllSessions() {
-    const promises = Object.keys(this.knownSessions).map(key => this.knownSessions[key]);
+    const promises = Object.keys(this.knownSessionPromises).map(
+      key => this.knownSessionPromises[key]
+    );
     return Promise.all(promises);
   }
 
@@ -85,19 +114,14 @@ class SessionManager {
   }
 
   hasSession(type) {
-    return !!this.knownSessions[type];
+    return !!this.knownSessionPromises[type];
   }
 
   async closeSession(session) {
-    if (!this.hasSession(session.type)) {
-      return;
-    }
-    delete this.knownSessions[session.type];
-    try {
-      await apiHelper.closeSession({ session: session, silenceErrors: true });
-    } catch (err) {
-      console.warn(err);
+    if (this.hasSession(session.type)) {
+      delete this.knownSessionPromises[session.type];
     }
+    await apiHelper.closeSession({ session: session, silenceErrors: true });
   }
 }
 

+ 8 - 0
desktop/core/src/desktop/js/apps/notebook2/execution/spec/executableStatementSpec.js

@@ -82,6 +82,14 @@ describe('executableStatement.js', () => {
     const subject = createSubject('SELECT * FROM customers');
 
     const simplePostDeferred = $.Deferred();
+
+    spyOn(ApiHelper, 'createSession').and.callFake(
+      () =>
+        new Promise(resolve => {
+          resolve({ type: 'x' });
+        })
+    );
+
     spyOn(ApiHelper, 'simplePost').and.callFake(url => {
       expect(url).toEqual('/notebook/api/execute/impala');
       return simplePostDeferred;

+ 137 - 0
desktop/core/src/desktop/js/apps/notebook2/execution/spec/sessionManagerSpec.js

@@ -0,0 +1,137 @@
+// 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 ApiHelper from 'api/apiHelper';
+import sessionManager from '../sessionManager';
+
+describe('sessionManager.js', () => {
+  beforeEach(() => {
+    // sessionManager is a singleton so we need to clear out sessions between tests
+    sessionManager.knownSessionPromises = {};
+    const sessionCount = {};
+    const getSessionCount = type => {
+      if (!sessionCount[type]) {
+        sessionCount[type] = 0;
+      }
+      return sessionCount[type]++;
+    };
+    spyOn(ApiHelper, 'createSession').and.callFake(sessionDef =>
+      Promise.resolve({
+        session_id: sessionDef.type + '_' + getSessionCount(sessionDef.type),
+        type: sessionDef.type
+      })
+    );
+  });
+
+  afterEach(() => {
+    sessionManager.knownSessionPromises = {};
+  });
+
+  it('should create detached sessions', async () => {
+    const sessionDetails = {
+      type: 'impala',
+      properties: [{ key: 'someKey', value: 'someValue' }]
+    };
+
+    expect((await sessionManager.getAllSessions()).length).toEqual(0);
+
+    const session = await sessionManager.createDetachedSession(sessionDetails);
+
+    expect(session.session_id).toEqual('impala_0');
+
+    expect((await sessionManager.getAllSessions()).length).toEqual(0);
+    expect(sessionManager.hasSession('impala')).toBeFalsy();
+    expect(ApiHelper.createSession).toHaveBeenCalledWith(sessionDetails);
+  });
+
+  it('should keep one sessions instance per type', async () => {
+    expect((await sessionManager.getAllSessions()).length).toEqual(0);
+
+    let session = await sessionManager.getSession({ type: 'impala' });
+
+    expect(session.session_id).toEqual('impala_0');
+
+    session = await sessionManager.getSession({ type: 'impala' });
+
+    expect(session.session_id).toEqual('impala_0');
+
+    expect((await sessionManager.getAllSessions()).length).toEqual(1);
+    expect(sessionManager.hasSession('impala')).toBeTruthy();
+    expect(ApiHelper.createSession).toHaveBeenCalledTimes(1);
+  });
+
+  it('should keep track of multiple instance per type', async () => {
+    expect((await sessionManager.getAllSessions()).length).toEqual(0);
+
+    let session = await sessionManager.getSession({ type: 'impala' });
+
+    expect(session.session_id).toEqual('impala_0');
+
+    session = await sessionManager.getSession({ type: 'hive' });
+
+    expect(session.session_id).toEqual('hive_0');
+
+    expect((await sessionManager.getAllSessions()).length).toEqual(2);
+    expect(sessionManager.hasSession('impala')).toBeTruthy();
+    expect(sessionManager.hasSession('hive')).toBeTruthy();
+    expect(ApiHelper.createSession).toHaveBeenCalledTimes(2);
+  });
+
+  it('should stop tracking sessions when closed', async () => {
+    expect((await sessionManager.getAllSessions()).length).toEqual(0);
+
+    // Create a session
+    const session = await sessionManager.getSession({ type: 'impala' });
+
+    expect(session.session_id).toEqual('impala_0');
+    expect(sessionManager.hasSession('impala')).toBeTruthy();
+
+    // Close the session
+    spyOn(ApiHelper, 'simplePost').and.callFake((url, data, options) => {
+      expect(JSON.parse(data.session).session_id).toEqual(session.session_id);
+      expect(options.silenceErrors).toBeTruthy();
+      expect(url).toEqual('/notebook/api/close_session');
+      return new $.Deferred().resolve().promise();
+    });
+    await sessionManager.closeSession(session);
+
+    expect(sessionManager.hasSession('impala')).toBeFalsy();
+    expect(ApiHelper.createSession).toHaveBeenCalledTimes(1);
+    expect(ApiHelper.simplePost).toHaveBeenCalledTimes(1);
+  });
+
+  it('should be able to restart sessions', async () => {
+    expect((await sessionManager.getAllSessions()).length).toEqual(0);
+
+    // Create a session
+    let session = await sessionManager.getSession({ type: 'impala' });
+
+    expect(session.session_id).toEqual('impala_0');
+    expect(sessionManager.hasSession('impala')).toBeTruthy();
+
+    // Restart the session
+    spyOn(ApiHelper, 'simplePost').and.returnValue(new $.Deferred().resolve().promise());
+    session = await sessionManager.restartSession(session);
+
+    expect(session.session_id).toEqual('impala_1');
+    expect(sessionManager.hasSession('impala')).toBeTruthy();
+
+    expect(ApiHelper.createSession).toHaveBeenCalledTimes(2);
+    expect(ApiHelper.simplePost).toHaveBeenCalledTimes(1);
+  });
+});

+ 0 - 43
desktop/core/src/desktop/js/apps/notebook2/session.js

@@ -1,43 +0,0 @@
-// 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 ko from 'knockout';
-import komapping from 'knockout.mapping';
-
-class Session {
-  constructor(vm, session) {
-    const self = this;
-    komapping.fromJS(session, {}, self);
-
-    self.selectedSessionProperty = ko.observable('');
-
-    self.restarting = ko.observable(false);
-
-    if (!ko.isObservable(self.properties)) {
-      self.properties = ko.observableArray();
-    }
-
-    self.availableNewProperties = ko.pureComputed(() => {
-      const addedIndex = {};
-      self.properties().forEach(property => {
-        addedIndex[property.key] = true;
-      });
-      return vm.availableSessionProperties().filter(property => !addedIndex[property.name]);
-    });
-  }
-}
-
-export default Session;

+ 16 - 15
desktop/core/src/desktop/js/ko/components/componentUtils.js

@@ -14,29 +14,30 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import $ from 'jquery';
 import ko from 'knockout';
 
 const instances = {};
 
 class componentUtils {
-  static registerComponent(name, model, template) {
-    const deferred = $.Deferred();
-    if (!ko.components.isRegistered(name)) {
-      const componentInfo = {
-        template: template
-      };
-      if (model) {
-        componentInfo['viewModel'] = model;
+  static async registerComponent(name, model, template) {
+    return new Promise((resolve, reject) => {
+      if (!ko.components.isRegistered(name)) {
+        const componentInfo = {
+          template: template
+        };
+        if (model) {
+          componentInfo['viewModel'] = model;
+        }
+        ko.components.register(name, componentInfo);
+        resolve();
+      } else {
+        reject();
       }
-      ko.components.register(name, componentInfo);
-      return deferred.resolve().promise();
-    }
-    return deferred.reject().promise();
+    });
   }
 
-  static registerStaticComponent(name, model, template) {
-    componentUtils.registerComponent(
+  static async registerStaticComponent(name, model, template) {
+    return componentUtils.registerComponent(
       name,
       {
         createViewModel: (params, componentInfo) => {

+ 1 - 1
desktop/core/src/desktop/js/ko/components/ko.createDirectoryModal.js

@@ -47,7 +47,7 @@ const TEMPLATE = `
   <!-- /ko -->
 `;
 
-componentUtils.registerComponent('create-directory', undefined, TEMPLATE).done(() => {
+componentUtils.registerComponent('create-directory', undefined, TEMPLATE).then(() => {
   huePubSub.subscribe('show.create.directory.modal', docViewModel => {
     let $createDirectoryModal = $('#createDirectoryModal');
     if ($createDirectoryModal.length > 0) {

+ 1 - 1
desktop/core/src/desktop/js/ko/components/ko.deleteDocModal.js

@@ -75,7 +75,7 @@ const TEMPLATE = `
   <!-- /ko -->
 `;
 
-componentUtils.registerComponent('delete-entry', undefined, TEMPLATE).done(() => {
+componentUtils.registerComponent('delete-entry', undefined, TEMPLATE).then(() => {
   huePubSub.subscribe('doc.show.delete.modal', docViewModel => {
     let $deleteEntriesModal = $('#deleteEntriesModal');
     if ($deleteEntriesModal.length > 0) {

+ 138 - 0
desktop/core/src/desktop/js/ko/components/ko.sessionAuthModal.js

@@ -0,0 +1,138 @@
+// 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 ko from 'knockout';
+
+import componentUtils from './componentUtils';
+import huePubSub from 'utils/huePubSub';
+import I18n from 'utils/i18n';
+import sessionManager from 'apps/notebook2/execution/sessionManager';
+
+const TEMPLATE = `
+<div class="modal-header">
+  <button type="button" class="close" data-dismiss="modal" aria-label="${I18n(
+    'Close'
+  )}"><span aria-hidden="true">&times;</span></button>
+  <h2 class="modal-title">${I18n('Connect to the data source')}</h2>
+</div>
+<div class="modal-body">
+  <!-- ko if: authSessionMessage -->
+    <div class="row-fluid">
+      <div class="alert-warning">
+        <span data-bind="text: authSessionMessage"></span>
+      </div>
+    </div>
+  <!-- /ko -->
+  <div class="row-fluid">
+    <div class="span6">
+      <div class="input-prepend">
+        <span class="add-on muted"><i class="fa fa-user"></i></span>
+        <input name="username" type="text" data-bind="value: $root.authSessionUsername" placeholder="${I18n(
+          'Username'
+        )}"/>
+      </div>
+    </div>
+    <div class="span6">
+      <div class="input-prepend">
+        <span class="add-on muted"><i class="fa fa-lock"></i></span>
+        <input name="password" type="password" data-bind="value: $root.authSessionPassword" placeholder="${I18n(
+          'Password'
+        )}"/>
+      </div>
+    </div>
+  </div>
+</div>
+<div class="modal-footer">
+  <a class="btn" data-dismiss="modal">${I18n('Cancel')}</a>
+  <a class="btn btn-primary disable-feedback" data-bind="click: connect, css: { 'disabled': loading }"><i class='fa fa-spinner fa-spin' data-bind="visible: loading"></i> ${I18n(
+    'Connect'
+  )}</a>
+</div>
+`;
+
+class SessionAuthModal {
+  constructor(params, $modal) {
+    this.pending = true;
+    this.loading = ko.observable(false);
+    this.reject = params.reject;
+    this.resolve = params.resolve;
+    this.session = params.session;
+    this.authSessionMessage = params.message;
+    this.$modal = $modal;
+
+    let username = '';
+    this.session.properties.some(property => {
+      if (property.name === 'user') {
+        username = property.value;
+      }
+    });
+    this.authSessionUsername = ko.observable(username);
+    this.authSessionPassword = ko.observable();
+  }
+
+  async connect() {
+    this.loading(true);
+    const properties = this.session.properties.filter(
+      property => property.name !== 'user' && property.name !== 'password'
+    );
+    properties.push({ name: 'user', value: this.authSessionUsername() });
+    properties.push({ name: 'password', value: this.authSessionPassword() });
+    try {
+      const session = await sessionManager.createDetachedSession({
+        type: this.session.type,
+        properties: properties,
+        preventAuthModal: true
+      });
+      // The modal could be closed before creation
+      if (this.pending) {
+        this.resolve(session);
+      }
+      this.pending = false;
+      this.$modal.modal('hide');
+    } catch (reason) {
+      if (reason && reason.auth) {
+        this.authSessionMessage(reason.message);
+      }
+    }
+    this.loading(false);
+  }
+}
+
+componentUtils.registerComponent('session-auth-modal', undefined, TEMPLATE).then(() => {
+  huePubSub.subscribe('show.session.auth.modal', sessionAuthParams => {
+    let $sessionAuthModal = $('#sessionAuthModal');
+    if ($sessionAuthModal.length > 0) {
+      ko.cleanNode($sessionAuthModal[0]);
+      $sessionAuthModal.remove();
+    }
+
+    $sessionAuthModal = $(
+      '<div id="sessionAuthModal" data-bind="component: { name: \'session-auth-modal\', params: $data }" data-keyboard="true" class="modal hide fade" tabindex="-1"/>'
+    );
+    $('body').append($sessionAuthModal);
+
+    const model = new SessionAuthModal(sessionAuthParams, $sessionAuthModal);
+    ko.applyBindings(model, $sessionAuthModal[0]);
+    $sessionAuthModal.modal('show');
+    $sessionAuthModal.on('hidden', () => {
+      if (model.pending) {
+        model.reject();
+        model.pending = false;
+      }
+    });
+  });
+});

+ 1 - 0
desktop/core/src/desktop/js/ko/ko.all.js

@@ -160,6 +160,7 @@ import 'ko/components/ko.navTags';
 import 'ko/components/ko.performanceGraph';
 import 'ko/components/ko.pollingCatalogEntriesList';
 import 'ko/components/ko.sentryPrivileges';
+import 'ko/components/ko.sessionAuthModal';
 import 'ko/components/ko.sessionPanel';
 import 'ko/components/ko.sidebar';
 import 'ko/components/ko.sqlColumnsTable';

+ 0 - 36
desktop/libs/notebook/src/notebook/templates/editor_components2.mako

@@ -1892,42 +1892,6 @@
     </div>
   </div>
 
-
-  <div id="authModal${ suffix }" class="modal hide fade">
-    <div class="modal-header">
-      <button type="button" class="close" data-dismiss="modal" aria-label="${ _('Close') }"><span aria-hidden="true">&times;</span></button>
-      <h2 class="modal-title">${_('Connect to the data source')}</h2>
-    </div>
-    <div class="modal-body">
-      <!-- ko if: $root.authSessionMessage() -->
-      <div class="row-fluid">
-        <div class="alert-warning">
-          <span data-bind="text: authSessionMessage"></span>
-        </div>
-      </div>
-      <!-- /ko -->
-      <div class="row-fluid">
-        <div class="span6">
-          <div class="input-prepend">
-            <span class="add-on muted"><i class="fa fa-user"></i></span>
-            <input name="username" type="text" data-bind="value: $root.authSessionUsername" placeholder="${ _('Username') }"/>
-          </div>
-        </div>
-        <div class="span6">
-          <div class="input-prepend">
-            <span class="add-on muted"><i class="fa fa-lock"></i></span>
-            <input name="password" type="password" data-bind="value: $root.authSessionPassword" placeholder="${ _('Password') }"/>
-          </div>
-        </div>
-      </div>
-    </div>
-    <div class="modal-footer">
-      <a class="btn" data-dismiss="modal">${_('Cancel')}</a>
-      <a class="btn btn-primary disable-feedback" data-dismiss="modal" data-bind="click: function() { $root.selectedNotebook().authSession(); }">${_('Connect')}</a>
-    </div>
-  </div>
-
-
   <div id="clearHistoryModal${ suffix }" class="modal hide fade">
     <div class="modal-header">
       <button type="button" class="close" data-dismiss="modal" aria-label="${ _('Close') }"><span aria-hidden="true">&times;</span></button>