浏览代码

HUE-1828 [beeswax] Add back download and save results

Make save results more ajaxy.
Fix save results tests.
Abraham Elmahrek 12 年之前
父节点
当前提交
0787bf2

+ 69 - 0
apps/beeswax/src/beeswax/api.py

@@ -327,6 +327,75 @@ def cancel_query(request, query_id):
   return HttpResponse(json.dumps(response), mimetype="application/json")
 
 
+@error_handler
+def save_results(request, query_id):
+  """
+  Save the results of a query to an HDFS directory or Hive table.
+  """
+  response = {'status': 0, 'message': ''}
+
+  query_history = authorized_get_history(request, query_id, must_exist=True)
+  server_id, state = _get_query_handle_and_state(query_history)
+  query_history.save_state(state)
+  error_msg, log = None, None
+
+  if request.method != 'POST':
+    response['message'] = _('A POST request is required.')
+  else:
+    if not query_history.is_success():
+      response['message'] = _('This query is %(state)s. Results unavailable.') % {'state': state}
+      response['status'] = -1
+      return HttpResponse(json.dumps(response), mimetype="application/json")
+
+    # massage data to work with old forms
+    data = {}
+    if request.POST.get('type') == 'hive-table':
+      data['save_target'] = 'to a new table'
+      data['target_table'] = request.POST.get('path', None)
+    elif request.POST.get('type') == 'hdfs':
+      data['save_target'] = 'to HDFS directory'
+      data['target_dir'] = request.POST.get('path', None)
+    else:
+      data['save_target'] = None
+      data['target_table'] = request.POST.get('path', None)
+      data['target_dir'] = request.POST.get('path', None)
+
+    db = dbms.get(request.user, query_history.get_query_server_config())
+    form = beeswax.forms.SaveResultsForm(data, db=db, fs=request.fs)
+
+    if form.is_valid():
+      try:
+        handle, state = _get_query_handle_and_state(query_history)
+        result_meta = db.get_results_metadata(handle)
+      except Exception, ex:
+        response['message'] = _('Cannot find query: %s') % {'state': state}
+        response['status'] = -2
+        return HttpResponse(json.dumps(response), mimetype="application/json")
+
+      try:
+        if form.cleaned_data['save_target'] == form.SAVE_TYPE_DIR:
+          target_dir = form.cleaned_data['target_dir']
+          query_history = db.insert_query_into_directory(query_history, target_dir)
+          response['type'] = 'hdfs'
+          response['id'] = query_history.id
+          response['query'] = query_history.query
+          response['path'] = target_dir
+          response['watch_url'] = reverse(get_app_name(request) + ':watch_query_refresh_json', kwargs={'id': query_history.id})
+        elif form.cleaned_data['save_target'] == form.SAVE_TYPE_TBL:
+          db.create_table_as_a_select(request, query_history, form.cleaned_data['target_table'], result_meta)
+          response['type'] = 'hive-table'
+          response['path'] = form.cleaned_data['target_table']
+      except Exception, ex:
+        error_msg, log = expand_exception(ex, db)
+        response['message'] = _('The result could not be saved: %s.') % error_msg
+        response['status'] = -3
+    else:
+      response['status'] = 1
+      response['errors'] = form.errors
+
+  return HttpResponse(json.dumps(response), mimetype="application/json")
+
+
 def design_to_dict(design):
   hql_design = HQLdesign.loads(design.data)
   return {

+ 19 - 16
apps/beeswax/src/beeswax/forms.py

@@ -105,22 +105,25 @@ class SaveResultsForm(DependencyAwareForm):
   def clean(self):
     cleaned_data = super(SaveResultsForm, self).clean()
 
-    if cleaned_data.get('save_target') == SaveResultsForm.SAVE_TYPE_TBL:
-      tbl = cleaned_data.get('target_table')
-      if tbl:
-        try:
-          if self.db is not None:
-            self.db.get_table('default', tbl) # Assumes 'default' DB
-          self._errors['target_table'] = self.error_class([_('Table already exists')])
-          del cleaned_data['target_table']
-        except Exception:
-          pass
-    elif cleaned_data['save_target'] == SaveResultsForm.SAVE_TYPE_DIR:
-      target_dir = cleaned_data['target_dir']
-      if not target_dir.startswith('/'):
-        self._errors['target_dir'] = self.error_class([_('Directory should start with /')])
-      elif self.fs.exists(target_dir):
-        self._errors['target_dir'] = self.error_class([_('Directory already exists.')]) # Overwrite destination directory content
+    if cleaned_data:
+      if cleaned_data.get('save_target') == SaveResultsForm.SAVE_TYPE_TBL:
+        tbl = cleaned_data.get('target_table')
+        if tbl:
+          try:
+            if self.db is not None:
+              self.db.get_table('default', tbl) # Assumes 'default' DB
+            self._errors['target_table'] = self.error_class([_('Table already exists')])
+            del cleaned_data['target_table']
+          except Exception:
+            pass
+      elif cleaned_data['save_target'] == SaveResultsForm.SAVE_TYPE_DIR:
+        target_dir = cleaned_data['target_dir']
+        if not target_dir.startswith('/'):
+          self._errors['target_dir'] = self.error_class([_('Directory should start with /')])
+        elif self.fs.exists(target_dir):
+          self._errors['target_dir'] = self.error_class([_('Directory already exists.')]) # Overwrite destination directory content
+    else:
+      self._errors['save_target'] = self.error_class([_('Please select a save target.')])
 
     return cleaned_data
 

+ 109 - 25
apps/beeswax/src/beeswax/templates/execute.mako

@@ -226,8 +226,8 @@ ${layout.menubar(section='query')}
           <textarea class="hide" tabindex="1" name="query" id="queryField"></textarea>
 
           <div class="actions">
-            <button data-bind="click: tryExecuteQuery, visible: !$root.isRunning()" type="button" id="executeQuery" class="btn btn-primary disable-feedback" tabindex="2">${_('Execute')}</button>
-            <button data-bind="click: tryCancelQuery, visible: $root.isRunning()" class="btn btn-danger" data-loading-text="${ _('Canceling...') }" rel="tooltip" data-original-title="${ _('Cancel the query') }">${ _('Cancel') }</button>
+            <button data-bind="click: tryExecuteQuery, visible: !$root.query.isRunning()" type="button" id="executeQuery" class="btn btn-primary disable-feedback" tabindex="2">${_('Execute')}</button>
+            <button data-bind="click: tryCancelQuery, visible: $root.query.isRunning()" class="btn btn-danger" data-loading-text="${ _('Canceling...') }" rel="tooltip" data-original-title="${ _('Cancel the query') }">${ _('Cancel') }</button>
             <button data-bind="click: trySaveQuery, css: {'hide': !$root.query.id() || $root.query.id() == -1}" type="button" class="btn hide">${_('Save')}</button>
             <button data-bind="click: saveAsModal" type="button" class="btn">${_('Save as...')}</button>
             <button data-bind="click: tryExplainQuery" type="button" id="explainQuery" class="btn">${_('Explain')}</button>
@@ -242,7 +242,18 @@ ${layout.menubar(section='query')}
   </div>
 
   <div class="card card-small scrollable resultsContainer">
-    <a id="expandResults" href="javascript:void(0)" class="view-query-results hide pull-right"><h4 style="margin-right: 20px"><i class="fa fa-expand"></i></h4></a>
+    <a id="expandResults" href="javascript:void(0)" title="${_('See results in full screen')}" rel="tooltip"
+      class="view-query-results hide pull-right"><h4 style="margin-right: 20px"><i class="fa fa-expand"></i></h4></a>
+
+    <a id="save-results" data-bind="click: saveResultsModal" href="javascript:void(0)" title="${_('Save the results to HDFS or a new Hive table')}" rel="tooltip"
+      class="view-query-results hide pull-right"><h4 style="margin-right: 20px"><i class="fa fa-save"></i></h4></a>
+
+    <a id="download-csv" data-bind="attr: {'href': '/beeswax/download/' + $root.query.id() + '/csv'}" href="javascript:void(0)" title="${_('Download the results in CSV format')}" rel="tooltip"
+      class="view-query-results hide pull-right"><h4 style="margin-right: 20px"><i class="fa fa-arrow-circle-o-down"></i></h4></a>
+
+    <a id="download-excel" data-bind="attr: {'href': '/beeswax/download/' + $root.query.id() + '/xls'}" href="javascript:void(0)" title="${_('Download the results for excel')}" rel="tooltip"
+      class="view-query-results hide pull-right"><h4 style="margin-right: 20px"><i class="fa fa-arrow-circle-o-down"></i></h4></a>
+
     <div class="card-body">
       <ul class="nav nav-tabs">
         <!-- ko if: !query.explain() -->
@@ -265,19 +276,19 @@ ${layout.menubar(section='query')}
         </div>
         <!-- ko if: query.explain() -->
         <div class="tab-pane" id="explanation">
-          <pre data-bind="text: viewModel.explanation()"></pre>
+          <pre data-bind="text: $root.query.results.explanation()"></pre>
         </div>
         <!-- /ko -->
         <!-- ko if: !query.explain() -->
         <div class="active tab-pane" id="log">
-          <pre data-bind="text: viewModel.logs().join('\n')"></pre>
+          <pre data-bind="text: $root.query.watch.logs().join('\n')"></pre>
         </div>
         <div class="tab-pane" id="columns">
           <table class="table table-striped table-condensed" cellpadding="0" cellspacing="0">
             <thead>
               <tr><th>${_('Name')}</th></tr>
             </thead>
-            <tbody data-bind="foreach: columns">
+            <tbody data-bind="foreach: $root.query.results.columns">
               <tr>
                 <td><a href="javascript:void(0)" class="column-selector" data-bind="text: $data.name"></a></td>
               </tr>
@@ -285,17 +296,17 @@ ${layout.menubar(section='query')}
           </table>
         </div>
         <div class="tab-pane" id="results">
-          <div data-bind="css: {'hide': rows().length == 0}" class="hide">
+          <div data-bind="css: {'hide': $root.query.results.rows().length == 0}" class="hide">
             <table class="table table-striped table-condensed resultTable" cellpadding="0" cellspacing="0" data-tablescroller-min-height-disable="true" data-tablescroller-enforce-height="true">
               <thead>
-              <tr data-bind="foreach: columns">
+              <tr data-bind="foreach: $root.query.results.columns">
                 <th data-bind="text: $data.name, css: { 'sort-numeric': $.inArray($data.type, ['TINYINT_TYPE', 'SMALLINT_TYPE', 'INT_TYPE', 'BIGINT_TYPE', 'FLOAT_TYPE', 'DOUBLE_TYPE', 'DECIMAL_TYPE']) > -1, 'sort-date': $.inArray($data.type, ['TIMESTAMP_TYPE', 'DATE_TYPE']) > -1, 'sort-string': $.inArray($data.type, ['TINYINT_TYPE', 'SMALLINT_TYPE', 'INT_TYPE', 'BIGINT_TYPE', 'FLOAT_TYPE', 'DOUBLE_TYPE', 'DECIMAL_TYPE', 'TIMESTAMP_TYPE', 'DATE_TYPE']) == -1 }"></th>
               </tr>
               </thead>
             </table>
           </div>
 
-          <div data-bind="css: {'hide': !resultsEmpty()}" class="hide">
+          <div data-bind="css: {'hide': !$root.query.results.empty()}" class="hide">
             <div class="card card-small scrollable">
               <div class="row-fluid">
                 <div class="span10 offset1 center empty-wrapper">
@@ -374,16 +385,13 @@ ${layout.menubar(section='query')}
   <div class="row-fluid">
     <div class="card card-small">
       <h1 class="card-heading simple">${_('Please specify parameters for this query')}</h1>
-
       <div class="card-body">
         <p>
-
         <form method="POST" action="" class="form-horizontal">
           <fieldset>
             <!-- ko foreach: $root.query.parameters -->
             <div class="control-group">
               <label data-bind="text: name" class="control-label"></label>
-
               <div class="controls">
                 <input data-bind="value: value" type="text"/>
               </div>
@@ -448,6 +456,7 @@ ${layout.menubar(section='query')}
   </div>
 </div>
 
+
 <div id="saveAs" class="modal hide fade">
   <div class="modal-header">
     <a href="#" class="close" data-dismiss="modal">&times;</a>
@@ -476,6 +485,55 @@ ${layout.menubar(section='query')}
   </div>
 </div>
 
+
+<div id="saveResultsModal" class="modal hide fade">
+  <div class="modal-header">
+    <a href="#" class="close" data-dismiss="modal">&times;</a>
+    <h3>${_('Save Query Results')}</h3>
+  </div>
+  <div class="modal-body">
+    <!-- ko if: $root.query.results.save.saveTargetError() -->
+      <h4 data-bind="text: $root.query.results.save.saveTargetError()"></h4>
+    <!-- /ko -->
+    <!-- ko if: $root.query.results.save.targetTableError() -->
+      <h4 data-bind="text: $root.query.results.save.targetTableError()"></h4>
+    <!-- /ko -->
+    <!-- ko if: $root.query.results.save.targetDirectoryError() -->
+      <h4 data-bind="text: $root.query.results.save.targetDirectoryError()"></h4>
+    <!-- /ko -->
+    <form id="saveResultsForm" method="POST" class="form form-inline">
+      <fieldset>
+        <div data-bind="css: {'error': $root.query.results.save.targetTableError()}" class="control-group">
+          <div class="controls">
+            <label class="radio">
+              <input data-bind="checked: $root.query.results.save.type" type="radio" name="save-results-type" value="hive-table">
+              &nbsp;${ _('In a new table') }
+            </label>
+            <span data-bind="visible: $root.query.results.save.type() == 'hive-table'">
+              <input data-bind="value: $root.query.results.save.path" type="text" name="target_table" placeholder="${_('Table name')}">
+            </span>
+          </div>
+        </div>
+        <div data-bind="css: {'error': $root.query.results.save.targetDirectoryError()}" class="control-group">
+          <div class="controls">
+            <label class="radio">
+              <input data-bind="checked: $root.query.results.save.type" type="radio" name="save-results-type" value="hdfs">
+              &nbsp;${ _('In an HDFS directory') }
+            </label>
+            <span data-bind="visible: $root.query.results.save.type() == 'hdfs'">
+              <input data-bind="value: $root.query.results.save.path" type="text" name="target_dir" placeholder="${_('Results location')}" class="pathChooser">
+            </span>
+          </div>
+        </div>
+      </fieldset>
+    </form>
+  </div>
+  <div class="modal-footer">
+    <button class="btn" data-dismiss="modal">${_('Cancel')}</button>
+    <button data-bind="click: trySaveResults" class="btn btn-primary">${_('Save')}</button>
+  </div>
+</div>
+
 <div id="navigatorQuicklook" class="modal hide fade">
   <div class="modal-header">
     <a href="#" class="close" data-dismiss="modal">&times;</a>
@@ -540,6 +598,11 @@ ${layout.menubar(section='query')}
     width: 40px;
   }
 
+  .control-group label.radio {
+    float: none;
+    width: auto;
+  }
+
   .sidebar-nav {
     margin-bottom: 90px !important;
   }
@@ -585,6 +648,13 @@ ${layout.menubar(section='query')}
     font-size: 11px;
   }
 
+  .editorError {
+    color: #B94A48;
+    background-color: #F2DEDE;
+    padding: 4px;
+    font-size: 11px;
+  }
+
   .editable-empty, .editable-empty:hover {
     color: #666;
     font-style: normal;
@@ -1267,21 +1337,21 @@ function cleanResultsTable() {
   if (dataTable) {
     dataTable.fnClearTable();
     dataTable.fnDestroy();
-    viewModel.columns.valueHasMutated();
-    viewModel.rows.valueHasMutated();
+    viewModel.query.results.columns.valueHasMutated();
+    viewModel.query.results.rows.valueHasMutated();
     dataTable = null;
   }
 }
 
 function addResults(viewModel, dataTable, index, pageSize) {
-  if (viewModel.hasMoreResults() && index + pageSize > viewModel.rows().length) {
+  if (viewModel.hasMoreResults() && index + pageSize > viewModel.query.results.rows().length) {
     $(document).one('fetched.results', function () {
       $.totalStorage("${app_name}_temp_query", null);
-      dataTable.fnAddData(viewModel.rows.slice(index, index + pageSize));
+      dataTable.fnAddData(viewModel.query.results.rows.slice(index, index + pageSize));
     });
     viewModel.fetchResults();
   } else {
-    dataTable.fnAddData(viewModel.rows.slice(index, index + pageSize));
+    dataTable.fnAddData(viewModel.query.results.rows.slice(index, index + pageSize));
   }
 }
 
@@ -1327,7 +1397,7 @@ function resultsTable() {
     $(".dataTables_filter").hide();
     reinitializeTable();
     var _options = '<option value="-1">${ _("Please select a column")}</option>';
-    $(viewModel.columns()).each(function(cnt, item){
+    $(viewModel.query.results.columns()).each(function(cnt, item){
       _options += '<option value="'+(cnt + 1)+'">'+ item.name +'</option>';
     });
     $(".blueprintSelect").html(_options);
@@ -1378,6 +1448,12 @@ function trySaveQuery() {
   }
 }
 
+function saveAsModal() {
+  var query = getHighlightedQuery() || codeMirror.getValue();
+  viewModel.query.query(query);
+  $('#saveAs').modal('show');
+}
+
 function trySaveAsQuery() {
   if (viewModel.query.query() && viewModel.query.name()) {
     viewModel.query.id(-1);
@@ -1392,12 +1468,18 @@ function trySaveAsQuery() {
   }
 }
 
-function saveAsModal() {
-  var query = getHighlightedQuery() || codeMirror.getValue();
-  viewModel.query.query(query);
-  $('#saveAs').modal('show');
+function saveResultsModal() {
+  $('#saveResultsModal').modal('show');
 }
 
+function trySaveResults() {
+  viewModel.saveResults();
+}
+
+$(document).on('saved.results', function() {
+  $('#saveResultsModal').modal('hide');
+});
+
 
 // Querying and click events.
 function tryExecuteQuery() {
@@ -1513,6 +1595,8 @@ $(document).on('cancelled.query', function (e) {
 });
 
 $(document).ready(function () {
+  $(".pathChooser:not(:has(~ button))").after(getFileBrowseButton($(".pathChooser:not(:has(~ button))")));
+
   $("*[rel=tooltip]").tooltip({
     placement: 'bottom'
   });
@@ -1584,7 +1668,7 @@ $(document).ready(function () {
       showSection('explain-parameter-selection');
     },
     'query/logs': function () {
-      if (viewModel.logs().length == 0) {
+      if (viewModel.query.watch.logs().length == 0) {
         routie('query');
       }
       codeMirror.setSize("99%", 100);
@@ -1594,7 +1678,7 @@ $(document).ready(function () {
       clickHard('.resultsContainer .nav-tabs a[href="#log"]');
     },
     'query/results': function () {
-      if (viewModel.resultsEmpty()) {
+      if (viewModel.query.results.empty()) {
         routie('query');
       } else {
         codeMirror.setSize("99%", 100);
@@ -1605,7 +1689,7 @@ $(document).ready(function () {
       }
     },
     'query/explanation': function () {
-      if (!viewModel.explanation()) {
+      if (!viewModel.query.results.explanation()) {
         routie('query');
       }
       codeMirror.setSize("99%", 100);

+ 6 - 8
apps/beeswax/src/beeswax/tests.py

@@ -736,11 +736,10 @@ for x in sys.stdin:
     def save_and_verify(select_resp, target_dir, verify=True):
       qid = select_resp.context['query'].id
       save_data = {
-        'save_target': beeswax.forms.SaveResultsForm.SAVE_TYPE_DIR,
-        'target_dir': target_dir,
-        'save': True
+        'type': 'hdfs',
+        'path': target_dir
       }
-      resp = self.client.post('/beeswax/save_results/%s' % (qid,), save_data, follow=True)
+      resp = self.client.post('/beeswax/api/query/%s/results/save' % qid, save_data, follow=True)
       resp = wait_for_query_to_finish(self.client, resp, max=60)
 
       # Check that data is right
@@ -795,11 +794,10 @@ for x in sys.stdin:
       """Check that saving to table works"""
       qid = select_resp.context['query'].id
       save_data = {
-        'save_target': beeswax.forms.SaveResultsForm.SAVE_TYPE_TBL,
-        'target_table': target_tbl,
-        'save': True
+        'type': 'hive-table',
+        'path': target_tbl
       }
-      resp = self.client.post('/beeswax/save_results/%s' % (qid,), save_data, follow=True)
+      resp = self.client.post('/beeswax/api/query/%s/results/save' % (qid,), save_data, follow=True)
       wait_for_query_to_finish(self.client, resp, max=120)
 
       # Check that data is right. The SELECT may not give us the whole table.

+ 5 - 4
apps/beeswax/src/beeswax/urls.py

@@ -56,14 +56,15 @@ urlpatterns += patterns(
 urlpatterns += patterns(
   'beeswax.api',
 
-  url(r'^api/autocomplete/$', 'autocomplete', name='api_autocomplete'),
-  url(r'^api/autocomplete/(?P<database>\w+)/$', 'autocomplete', name='api_autocomplete'),
-  url(r'^api/autocomplete/(?P<database>\w+)/(?P<table>\w+)$', 'autocomplete', name='api_autocomplete'),
+  url(r'^api/autocomplete/$', 'autocomplete', name='api_autocomplete_databases'),
+  url(r'^api/autocomplete/(?P<database>\w+)/$', 'autocomplete', name='api_autocomplete_tables'),
+  url(r'^api/autocomplete/(?P<database>\w+)/(?P<table>\w+)$', 'autocomplete', name='api_autocomplete_columns'),
   url(r'^api/query/parameters$', 'parameters', name='api_parameters'),
   url(r'^api/query/execute/(?P<query_id>\d+)?$', 'execute', name='api_execute'),
   url(r'^api/query/(?P<query_id>\d+)?$', 'save_query', name='api_save_query'),
   url(r'^api/query/(?P<query_id>\d+)/get$', 'fetch_saved_query', name='api_fetch_saved_query'),
   url(r'^api/query/(?P<query_id>\d+)/cancel$', 'cancel_query', name='api_cancel_query'),
+  url(r'^api/query/(?P<query_id>\d+)/results/save$', 'save_results', name='api_save_results'),
   url(r'^watch/json/(?P<id>\d+)$', 'watch_query_refresh_json', name='watch_query_refresh_json'),
   url(r'^close_operation/(?P<query_id>\d+)?$', 'close_operation', name='close_operation'),
-)
+)

+ 3 - 3
apps/beeswax/src/beeswax/views.py

@@ -427,7 +427,7 @@ def execute_query(request, design_id=None):
     'error_message': error_message,
     'form': form,
     'log': log,
-    'autocomplete_base_url': reverse(get_app_name(request) + ':api_autocomplete', kwargs={}),
+    'autocomplete_base_url': reverse(get_app_name(request) + ':api_autocomplete_databases', kwargs={}),
     'on_success_url': on_success_url,
     'can_edit_name': design.id and not design.is_auto,
   })
@@ -630,6 +630,8 @@ def view_results(request, id, first_row=0):
 
 def save_results(request, id):
   """
+  DEPRECATED. Need to get rid of watch_wait dependency first.
+
   Save the results of a query to an HDFS directory or Hive table.
   """
   query_history = authorized_get_history(request, id, must_exist=True)
@@ -716,8 +718,6 @@ def install_examples(request):
   return HttpResponse(json.dumps(response), mimetype="application/json")
 
 
-
-
 @login_notrequired
 def query_done_cb(request, server_id):
   """

+ 106 - 37
apps/beeswax/static/js/beeswax.vm.js

@@ -33,19 +33,40 @@ function BeeswaxViewModel(server, query_id) {
     'isParameterized': true,
     'email': false,
     'errors': [],
-    'explain': false
+    'explain': false,
+    'results': {
+      'rows': [],
+      'columns': [],
+      'empty': true,
+      'explanation': null,
+      'url': null,
+      'save': {
+        'errors': null,
+        'type': 'table',
+        'path': null
+      }
+    },
+    'watch': {
+      'logs': [],
+      'url': null,
+    },
+    'isRunning': false
   });
-  self.logs = ko.observableArray();
-  self.explanation = ko.observable();
-  self.watchURL = ko.observable();
-  self.resultsURL = ko.observable(null);
-  self.columns = ko.observableArray();
-  self.rows = ko.observableArray();
-  self.resultsEmpty = ko.observable(true);
-  self.isRunning = ko.observable(false);
 
   self.hasMoreResults = ko.computed(function() {
-    return self.resultsURL() != null;
+    return self.query.results.url() != null;
+  });
+
+  self.query.results.save.saveTargetError = ko.computed(function() {
+    return (self.query.results.save.errors() && 'save_target' in self.query.results.save.errors()) ? self.query.results.save.errors()['save_target'] : null;
+  });
+
+  self.query.results.save.targetTableError = ko.computed(function() {
+    return (self.query.results.save.errors() && 'target_table' in self.query.results.save.errors()) ? self.query.results.save.errors()['target_table'] : null;
+  });
+
+  self.query.results.save.targetDirectoryError = ko.computed(function() {
+    return (self.query.results.save.errors() && 'target_dir' in self.query.results.save.errors()) ? self.query.results.save.errors()['target_dir'] : null;
   });
 
   self.database = ko.computed({
@@ -212,7 +233,7 @@ function BeeswaxViewModel(server, query_id) {
   };
 
   var error_fn = function(jqXHR, status, errorThrown) {
-    self.isRunning(false);
+    self.query.isRunning(false);
     try {
       $(document).trigger('server.error', $.parseJSON(jqXHR.responseText));
     } catch(e) {
@@ -278,7 +299,7 @@ function BeeswaxViewModel(server, query_id) {
   self.explainQuery = function() {
     $(document).trigger('explain.query', data);
     self.query.explain(true);
-    self.isRunning(true);
+    self.query.isRunning(true);
 
     var data = {
       'query-query': self.query.query(),
@@ -296,16 +317,16 @@ function BeeswaxViewModel(server, query_id) {
       success: function(data) {
         self.query.errors.removeAll();
         if (data.status == 0) {
-          self.logs.removeAll();
-          self.rows.removeAll();
-          self.columns.removeAll();
-          self.explanation(data.explanation);
+          self.query.watch.logs.removeAll();
+          self.query.results.rows.removeAll();
+          self.query.results.columns.removeAll();
+          self.query.results.explanation(data.explanation);
         } else {
           self.query.errors.push(data.message);
           $(document).trigger('error.query');
         }
         $(document).trigger('explained.query', data);
-        self.isRunning(false);
+        self.query.isRunning(false);
       },
       error: error_fn,
       data: data
@@ -316,7 +337,7 @@ function BeeswaxViewModel(server, query_id) {
   self.executeQuery = function() {
     $(document).trigger('execute.query', data);
     self.query.explain(false);
-    self.isRunning(true);
+    self.query.isRunning(true);
 
     var data = {
       'query-query': self.query.query(),
@@ -335,11 +356,8 @@ function BeeswaxViewModel(server, query_id) {
         self.query.errors.removeAll();
         if (data.status == 0) {
           self.query.id(data.id);
-          self.logs.removeAll();
-          self.rows.removeAll();
-          self.columns.removeAll();
-          self.resultsURL('/' + self.server() + '/results/' + self.query.id() + '/0?format=json');
-          self.watchURL(data.watch_url);
+          self.query.results.url('/' + self.server() + '/results/' + self.query.id() + '/0?format=json');
+          self.query.watch.url(data.watch_url);
           self.watchQueryLoop();
         } else {
           self.query.errors.push(data.message);
@@ -364,7 +382,7 @@ function BeeswaxViewModel(server, query_id) {
     $.extend(data, self.getParametersFormData());
     $.extend(data, self.getOtherData());
     var request = {
-      url: self.watchURL(),
+      url: self.query.watch.url(),
       dataType: 'json',
       type: 'POST',
       success: function(data) {
@@ -376,46 +394,55 @@ function BeeswaxViewModel(server, query_id) {
     $.ajax(request);
   };
 
-  self.watchQueryLoop = function() {
+  self.watchQueryLoop = function(fn) {
     var TIMEOUT = 1000;
     var timer = null;
     var executed_once = false;
-    var fn = function() {
+
+    self.query.watch.logs.removeAll();
+    self.query.results.rows.removeAll();
+    self.query.results.columns.removeAll();
+
+    var _fn = function() {
       $(document).one('watched.query', function(e, data) {
         if (executed_once && (data.isSuccess || data.isFailure)) {
           clearTimeout(timer);
           $(document).trigger('stop_watch.query');
-          self.fetchResults();
+          if (fn) {
+            fn(data);
+          } else {
+            self.fetchResults();
+          }
         } else {
           executed_once = true;
           if (data.log) {
-            self.logs.push(data.log);
+            self.query.watch.logs.push(data.log);
             // scroll logs
           }
-          timer = setTimeout(fn, TIMEOUT);
+          timer = setTimeout(_fn, TIMEOUT);
         }
       });
       self.watchQuery();
     };
     $(document).trigger('start_watch.query');
-    timer = setTimeout(fn, TIMEOUT);
+    timer = setTimeout(_fn, TIMEOUT);
   };
 
   self.fetchResults = function() {
     $(document).trigger('fetch.results');
     var request = {
-      url: self.resultsURL(),
+      url: self.query.results.url(),
       dataType: 'json',
       type: 'GET',
       success: function(data) {
-        self.isRunning(false);
-        self.columns(data.columns);
-        self.rows.push.apply(self.rows, data.results);
-        self.resultsEmpty(self.rows().length == 0);
+        self.query.isRunning(false);
+        self.query.results.columns(data.columns);
+        self.query.results.rows.push.apply(self.query.results.rows, data.results);
+        self.query.results.empty(self.query.results.rows().length == 0);
         if (data.has_more) {
-          self.resultsURL(data.next_json_set);
+          self.query.results.url(data.next_json_set);
         } else {
-          self.resultsURL(null);
+          self.query.results.url(null);
         }
         $(document).trigger('fetched.results', data);
       },
@@ -476,6 +503,48 @@ function BeeswaxViewModel(server, query_id) {
     );
   };
 
+  self.saveResults = function() {
+    var self = this;
+    if (self.query.id()) {
+      var data = {
+        'type': self.query.results.save.type(),
+        'path': self.query.results.save.path()
+      };
+      var url = '/' + self.server() + '/api/query/' + self.query.id() + '/results/save';
+      var request = {
+        url: url,
+        dataType: 'json',
+        type: 'POST',
+        success: function(data) {
+          if (data.status == 0) {
+            self.query.results.save.errors(null);
+            if (data.id) {
+              // watch this ID.
+              self.query.watch.url(data.watch_url);
+              self.query.watch.logs.removeAll();
+              self.watchQueryLoop(function() {
+                window.location.href = "/filebrowser/view" + data.path;
+              });
+            } else {
+              // redirect to metastore app.
+              window.location.href = "/metastore";
+            }
+            $(document).trigger('saved.results', data);
+          } else {
+            self.query.results.save.errors(data.errors);
+            $(document).trigger('error_save.results');
+          }
+        },
+        error: function(data) {
+          self.query.results.save.errors(data);
+          $(document).trigger('error_save.results');
+        },
+        data: data
+      };
+      $.ajax(request);
+    }
+  };
+
   // Events
   // Remove watched query event that watchQueryLoop may be bound to.
   $(document).on('server.unmanageable_error', function() {

+ 1 - 1
desktop/core/src/desktop/lib/django_forms.py

@@ -510,7 +510,7 @@ class DependencyAwareForm(forms.Form):
     if self.errors:
       return
     for cond, required_value, child in self.dependencies:
-      if self.cleaned_data.get(cond) == required_value:
+      if self.cleaned_data.get(cond, None) == required_value:
         child_val = self.cleaned_data.get(child)
         if child_val in [None, '']:
           self._errors.setdefault(child, []).append("%s is required if %s is %s" % (child, cond, str(required_value)))