Browse Source

HUE-931 [oozie] Validate action before exiting edit modal

- Add 'errors' field to NodeModule as a list of error mappings
- Added tests validating node validation
abec 13 years ago
parent
commit
9ab1333

+ 5 - 5
apps/oozie/src/oozie/templates/editor/action_utils.mako

@@ -28,7 +28,7 @@
   <div data-bind="with: context">
     <form class="form-horizontal" id="${node_type}-action-form" method="POST">
       <div class="modal-header">
-        <a href="#" class="close" data-dismiss="modal">&times;</a>
+        <a href="#" class="close">&times;</a>
         <h3 class="message" data-bind="text: '${_('Edit Node: ')}' + name()"></h3>
       </div>
 
@@ -36,7 +36,7 @@
         <fieldset class="span12">
           % for field in action_form:
             % if field.html_name in ('name', 'description'):
-              ${ utils.render_field(field, extra_attrs={'data-bind': 'value: %s' % field.name}) }
+              ${ utils.render_field_with_error_js(field, field.name, extra_attrs={'data-bind': 'value: %s' % field.name}) }
             % endif
           % endfor
 
@@ -62,7 +62,7 @@
 
           % for field in action_form:
             % if field.html_name not in ('name', 'description', 'node_type', 'job_xml'):
-              ${ utils.render_field(field, extra_attrs={'data-bind': 'value: %s' % field.name}) }
+              ${ utils.render_field_with_error_js(field, field.name, extra_attrs={'data-bind': 'value: %s' % field.name}) }
             % endif
           % endfor
 
@@ -174,14 +174,14 @@
           % endif
 
           % if 'job_xml' in action_form.fields:
-            ${ utils.render_field(action_form['job_xml'], extra_attrs={'data-bind': 'value: %s' % action_form['job_xml'].name}) }
+            ${ utils.render_field_with_error_js(action_form['job_xml'], action_form['job_xml'].name, extra_attrs={'data-bind': 'value: %s' % action_form['job_xml'].name}) }
           % endif
 
         </fieldset>
       </div>
 
       <div class="modal-footer">
-        <button data-dismiss="modal" class="btn btn-primary doneButton">${ _('Done')}</button>
+        <button class="btn btn-primary doneButton" type="button">${ _('Done')}</button>
       </div>
 
     </form>

+ 50 - 14
apps/oozie/src/oozie/templates/editor/edit_workflow.mako

@@ -552,20 +552,38 @@ var workflow = new Workflow({
   model: workflow_model,
   registry: registry
 });
-var modal = new Modal("#node-modal");
+var modal = new Modal($('#node-modal'));
 workflow.load();
 
 $('#workflow').on('click', '.edit-node-link', function(e) {
-  var context = ko.contextFor(this).$data;
-  modal.setTemplate(context.edit_template);
-  modal.show(context);
+  var node = ko.contextFor(this).$data;
+  var backup = ko.mapping.toJS(node);
+  normalize_model_fields(backup);
+
+  modal.setTemplate(node.edit_template);
+  modal.show(node);
   modal.recenter(280, 250);
   modal.addDecorations();
-});
 
-$('.doneButton').live('click', function(){
-  workflow.model.is_dirty = true;
-})
+  var cancel_edit = function(e) {
+    ko.mapping.fromJS(backup, node);
+    modal.hide();
+
+    // Prevent event propagation
+    return false;
+  };
+
+  var try_save = function(e) {
+    if (node.validate()) {
+      workflow.model.is_dirty = true;
+      modal.hide();
+    }
+  };
+
+  $('.modal-backdrop').on('click', cancel_edit);
+  modal.el.on('click', '.close', cancel_edit);
+  modal.el.on('click', '.doneButton', try_save);
+});
 
 $('#workflow').on('click', '.new-node-link', function(e) {
   var node_type = $(this).attr('data-node-type');
@@ -575,22 +593,40 @@ $('#workflow').on('click', '.new-node-link', function(e) {
     id: IdGeneratorTable[node_type].nextId(),
     node_type: node_type
   });
-  var node = new Node(self, model, registry);
+  var node = new Node(workflow, model, registry);
 
   self.registry.add(model.id, node);
 
-  workflow.nodes()[workflow.nodes().length - 2].append(node);
-
-  $('#workflow').trigger('workflow:rebuild');
-
   modal.setTemplate(template);
   modal.show(node);
   modal.recenter(280, 250);
   modal.addDecorations();
+
+  var cancel_edit = function(e) {
+    // Didn't save, erase node.
+    node.erase();
+    modal.hide();
+  };
+
+  var try_save = function(e) {
+    if (node.validate()) {
+      workflow.model.is_dirty = true;
+      modal.hide();
+      // save, add node to workflow.
+      workflow.nodes()[workflow.nodes().length - 2].append(node);
+      $('#workflow').trigger('workflow:rebuild');
+    }
+  };
+
+  $('.modal-backdrop').on('click', cancel_edit);
+  modal.el.on('click', '.close', cancel_edit);
+  modal.el.on('click', '.doneButton', try_save);
+
+  workflow.model.is_dirty = true;
 });
 
 ko.bindingHandlers.fileChooser = {
-    init: function(element, valueAccessor, allBindings, model) {
+  init: function(element, valueAccessor, allBindings, model) {
     var self = $(element);
     self.after(getFileBrowseButton(self));
   }

+ 18 - 0
apps/oozie/src/oozie/templates/utils.inc.mako

@@ -184,6 +184,24 @@
 </%def>
 
 
+<%def name="render_field_with_error_js(field, error_name, show_label=True, extra_attrs={})">
+  % if not field.is_hidden:
+    <div class="control-group" rel="popover" data-original-title="${ field.label }" data-content="${ field.help_text }" data-bind="attr: {'class': ( errors.${ error_name }().length > 0 ) ? 'control-group error' : 'control-group'}">
+      % if show_label:
+        <label class="control-label">${ field.label | h }</label>
+      % endif
+      <div class="controls">
+        <% field.field.widget.attrs.update(extra_attrs) %>
+        ${ field }
+        <ul class="help-inline" data-bind="foreach: errors.${ error_name }()">
+          <li class="error" data-bind="html: $data"></li>
+        </ul>
+      </div>
+    </div>
+  %endif
+</%def>
+
+
 <%def name="render_constant(label, value)">
   <div class="control-group">
     <label class="control-label">${ label | h }</label>

+ 79 - 0
apps/oozie/src/oozie/tests.py

@@ -339,6 +339,85 @@ class TestAPI(OozieMockBase):
 
     assert_equal(0, test_response_json_object['status'])
 
+  def test_workflow_validate_action(self):
+    data = {"files":"[\"hive-site.xml\"]","job_xml":"hive-site.xml","description":"Show databases","workflow":17,"child_links":[{"comment":"","name":"ok","id":106,"parent":76,"child":74},{"comment":"","name":"error","id":107,"parent":76,"child":73}],"job_properties":"[{\"name\":\"oozie.hive.defaults\",\"value\":\"hive-site.xml\"}]","node_type":"hive","params":"[{\"value\":\"INPUT=/user/hue/oozie/workspaces/data\",\"type\":\"param\"}]","archives":"[]","node_ptr":76,"prepares":"[]","script_path":"hive.sql","id":76,"name":"Hive"}
+    response = self.c.post(reverse('oozie:workflow_validate_action', kwargs={'workflow': self.wf.pk, 'node_type': 'hive'}), data={'node': json.dumps(data)}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    test_response_json = response.content
+    test_response_json_object = json.loads(test_response_json)
+
+    assert_equal(0, test_response_json_object['status'])
+    assert_true('name' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['name']), test_response_json_object['data'])
+    assert_true('description' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['description']), test_response_json_object['data'])
+    assert_true('script_path' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['script_path']), test_response_json_object['data'])
+    assert_true('job_xml' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['job_xml']), test_response_json_object['data'])
+    assert_true('job_properties' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['job_properties']), test_response_json_object['data'])
+    assert_true('files' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['files']), test_response_json_object['data'])
+    assert_true('params' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['params']), test_response_json_object['data'])
+    assert_true('prepares' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['prepares']), test_response_json_object['data'])
+    assert_true('archives' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['archives']), test_response_json_object['data'])
+
+  def test_workflow_validate_action_fail(self):
+    # Empty files field
+    data = {"job_xml":"hive-site.xml","description":"Show databases","workflow":17,"child_links":[{"comment":"","name":"ok","id":106,"parent":76,"child":74},{"comment":"","name":"error","id":107,"parent":76,"child":73}],"job_properties":"[{\"name\":\"oozie.hive.defaults\",\"value\":\"hive-site.xml\"}]","node_type":"hive","params":"[{\"value\":\"INPUT=/user/hue/oozie/workspaces/data\",\"type\":\"param\"}]","archives":"[]","node_ptr":76,"prepares":"[]","script_path":"hive.sql","id":76,"name":"Hive"}
+    response = self.c.post(reverse('oozie:workflow_validate_action', kwargs={'workflow': self.wf.pk, 'node_type': 'hive'}), data={'node': json.dumps(data)}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    test_response_json = response.content
+    test_response_json_object = json.loads(test_response_json)
+
+    assert_equal(-1, test_response_json_object['status'])
+    assert_true('name' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['name']), test_response_json_object['data'])
+    assert_true('description' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['description']), test_response_json_object['data'])
+    assert_true('script_path' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['script_path']), test_response_json_object['data'])
+    assert_true('job_xml' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['job_xml']), test_response_json_object['data'])
+    assert_true('job_properties' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['job_properties']), test_response_json_object['data'])
+    assert_true('files' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(1, len(test_response_json_object['data']['files']), test_response_json_object['data'])
+    assert_true('params' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['params']), test_response_json_object['data'])
+    assert_true('prepares' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['prepares']), test_response_json_object['data'])
+    assert_true('archives' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['archives']), test_response_json_object['data'])
+
+    # Empty script path
+    data = {"files":"[\"hive-site.xml\"]","job_xml":"hive-site.xml","description":"Show databases","workflow":17,"child_links":[{"comment":"","name":"ok","id":106,"parent":76,"child":74},{"comment":"","name":"error","id":107,"parent":76,"child":73}],"job_properties":"[{\"name\":\"oozie.hive.defaults\",\"value\":\"hive-site.xml\"}]","node_type":"hive","params":"[{\"value\":\"INPUT=/user/hue/oozie/workspaces/data\",\"type\":\"param\"}]","archives":"[]","node_ptr":76,"prepares":"[]","script_path":"","id":76,"name":"Hive"}
+    response = self.c.post(reverse('oozie:workflow_validate_action', kwargs={'workflow': self.wf.pk, 'node_type': 'hive'}), data={'node': json.dumps(data)}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+    test_response_json = response.content
+    test_response_json_object = json.loads(test_response_json)
+
+    assert_equal(-1, test_response_json_object['status'])
+    assert_true('name' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['name']), test_response_json_object['data'])
+    assert_true('description' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['description']), test_response_json_object['data'])
+    assert_true('script_path' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(1, len(test_response_json_object['data']['script_path']), test_response_json_object['data'])
+    assert_true('job_xml' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['job_xml']), test_response_json_object['data'])
+    assert_true('job_properties' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['job_properties']), test_response_json_object['data'])
+    assert_true('files' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['files']), test_response_json_object['data'])
+    assert_true('params' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['params']), test_response_json_object['data'])
+    assert_true('prepares' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['prepares']), test_response_json_object['data'])
+    assert_true('archives' in test_response_json_object['data'], test_response_json_object['data'])
+    assert_equal(0, len(test_response_json_object['data']['archives']), test_response_json_object['data'])
+
 
 class TestApiPermissionsWithOozie(OozieBase):
 

+ 1 - 0
apps/oozie/src/oozie/urls.py

@@ -57,6 +57,7 @@ urlpatterns += patterns(
 
   url(r'^workflows/(?P<workflow>\d+)$', 'workflow', name='workflow'),
   url(r'^workflows/(?P<workflow>\d+)/save$', 'workflow_save', name='workflow_save'),
+  url(r'^workflows/(?P<workflow>\d+)/actions/(?P<node_type>\w+)/validate$', 'workflow_validate_action', name='workflow_validate_action'),
 )
 
 

+ 57 - 57
apps/oozie/src/oozie/views/api.py

@@ -26,6 +26,7 @@ from django.utils.translation import ugettext as _
 
 from desktop.lib.exceptions import StructuredException
 
+from oozie.forms import WorkflowForm, design_form_by_type
 from oozie.models import Workflow, Node, Link, NODE_TYPES, ACTION_TYPES
 from oozie.decorators import check_job_access_permission, check_job_edition_permission
 from oozie.utils import model_to_dict
@@ -34,54 +35,43 @@ from oozie.utils import model_to_dict
 LOG = logging.getLogger(__name__)
 
 
-def validate_json_node(json):
-  assert 'id' in json, "Member 'id' not in node."
-  assert 'node_type' in json, "Member 'node_type' not in node."
-  if json['node_type'] in ACTION_TYPES.keys():
-    assert 'name' in json, "Member 'name' not in node."
-    # assert 'description' in json, "Member 'description' not in node."
-  if 'child_links' not in json:
-    raise AssertionError("Member 'child_links' is missing.")
+JSON_FIELDS = ('parameters', 'job_properties', 'files', 'archives', 'prepares', 'params')
+def format_field_value(field, value):
+  if field in JSON_FIELDS:
+    if not isinstance(value, basestring):
+      return json.dumps(value)
+  return value
 
-  validate_json_links(json['child_links'])
 
+def format_dict_field_values(dictionary):
+  for key in dictionary:
+    dictionary[key] = format_field_value(key, dictionary[key])
+  return dictionary
 
-def validate_json_nodes(json):
-  if not isinstance(json, list):
-    raise AssertionError("Member 'nodes' is not a list.")
 
-  for node in json:
-    validate_json_node(node)
+def workflow_validate_action_json(node_type, node_dict, errors={}):
+  """
+  Validates a single action.
+  node_type is the node type of the action information passed.
+  node_dict is a dictionary describing the node.
+  errors is a dictionary that will be populated with any found errors.
+  Returns Boolean.
+  """
+  assert isinstance(errors, dict), "errors must be a dict."
+  form_class = design_form_by_type(node_type)
+  form = form_class(data=node_dict)
 
+  if form.is_valid():
+    for field in form.fields:
+      errors[field] = []
 
-def validate_json_link(json):
-  assert 'name' in json, "Member 'name' not in link."
-  assert 'comment' in json, "Member 'comment' not in link."
-  assert 'parent' in json, "Member 'parent' not in link."
-  assert 'child' in json, "Member 'child' not in link."
+    return True
 
+  else:
+    for field in form.fields:
+      errors[field] = form[field].errors
 
-def validate_json_links(json):
-  if not isinstance(json, list):
-    raise AssertionError("Member 'child_links' is not a list.")
-
-  for node in json:
-    validate_json_link(node)
-
-
-def validate_json_workflow(json):
-  assert 'name' in json, "Member 'name' not in link."
-  assert 'description' in json, "Member 'description' not in link."
-  assert 'job_properties' in json, "Member 'job_properties' not in link."
-  assert 'parameters' in json, "Member 'parameters' not in link."
-  assert 'is_shared' in json, "Member 'is_shared' not in link."
-  assert 'job_xml' in json, "Member 'job_xml' not in link."
-  assert 'deployment_dir' in json, "Member 'deployment_dir' not in link."
-
-  if 'nodes' not in json:
-    raise AssertionError(_("Member 'nodes' is missing."))
-
-  validate_json_nodes(json['nodes'])
+    return False
 
 
 def get_or_create_node(workflow, node_data):
@@ -107,10 +97,7 @@ def update_workflow(json_workflow):
 
   for key in json_workflow:
     if key not in ('nodes', 'start', 'end'):
-      if key in ('parameters', 'job_properties', 'files', 'archives', 'prepares', 'params'):
-        setattr(workflow, key, json.dumps(json_workflow[key]))
-      else:
-        setattr(workflow, key, json_workflow[key])
+      setattr(workflow, key, json_workflow[key])
 
   workflow.save()
 
@@ -118,7 +105,11 @@ def update_workflow(json_workflow):
 
 
 def update_workflow_nodes(workflow, json_nodes, id_map):
-  validate_json_nodes(json_nodes)
+  for json_node in json_nodes:
+    errors = {}
+    if json_node['node_type'] in ACTION_TYPES and not workflow_validate_action_json(json_node['node_type'], format_dict_field_values(json_node), errors):
+      raise StructuredException(code="INVALID_REQUEST_ERROR", message=_('Invalid action'), data={'errors': errors}, error_code=400)
+
   nodes = []
 
   for json_node in json_nodes:
@@ -130,13 +121,7 @@ def update_workflow_nodes(workflow, json_nodes, id_map):
 
     for key in json_node:
       if key not in ('node_ptr', 'child_nodes', 'workflow', 'id'):
-        if key in ('parameters', 'job_properties', 'files', 'archives', 'prepares', 'params'):
-          if isinstance(json_node[key], basestring):
-            setattr(node, key, json_node[key])
-          else:
-            setattr(node, key, json.dumps(json_node[key]))
-        else:
-          setattr(node, key, json_node[key])
+        setattr(node, key, format_field_value(key, json_node[key]))
 
     node.workflow = workflow
     node.save()
@@ -154,16 +139,31 @@ def update_workflow_nodes(workflow, json_nodes, id_map):
   return nodes
 
 
+@check_job_access_permission(exception_class=(lambda x: StructuredException(code="UNAUTHORIZED_REQUEST_ERROR", message=x, data=None, error_code=401)))
+@check_job_edition_permission(exception_class=(lambda x: StructuredException(code="UNAUTHORIZED_REQUEST_ERROR", message=x, data=None, error_code=401)))
+def workflow_validate_action(request, workflow, node_type):
+  response = {'status': -1, 'data': {}}
+
+  action_dict = format_dict_field_values(json.loads(str(request.POST.get('node'))))
+
+  if workflow_validate_action_json(node_type, action_dict, response['data']):
+    response['status'] = 0
+  else:
+    response['status'] = -1
+
+  return HttpResponse(json.dumps(response), mimetype="application/json")
+
+
 # Workflow and child links are SPECIAL.
 @check_job_access_permission(exception_class=(lambda x: StructuredException(code="UNAUTHORIZED_REQUEST_ERROR", message=x, data=None, error_code=401)))
 @check_job_edition_permission(exception_class=(lambda x: StructuredException(code="UNAUTHORIZED_REQUEST_ERROR", message=x, data=None, error_code=401)))
 def workflow_save(request, workflow):
-  json_workflow = json.loads(str(request.POST.get('workflow')))
+  json_workflow = format_dict_field_values(json.loads(str(request.POST.get('workflow'))))
+  json_workflow.setdefault('schema_version', workflow.schema_version)
 
-  try:
-    validate_json_workflow(json_workflow)
-  except AssertionError, e:
-    raise StructuredException(code="INVALID_REQUEST_ERROR", message=_('Error saving workflow'), data={'more': str(e)}, error_code=400)
+  form = WorkflowForm(data=json_workflow)
+  if not form.is_valid():
+    raise StructuredException(code="INVALID_REQUEST_ERROR", message=_('Error saving workflow'), data={'errors': form.errors}, error_code=400)
 
   json_nodes = json_workflow['nodes']
   id_map = {}

+ 138 - 5
apps/oozie/static/js/workflow.js

@@ -25,6 +25,20 @@ if (!('filter' in Array.prototype)) {
   };
 }
 
+// Since knockout maps arrays without calling "update" nor "create"
+// Provide a JSON string that will be parsed in custom 'create' and 'update' functions.
+var normalize_model_fields = (function() {
+  var FIELDS = ['parameters', 'job_properties', 'files', 'archives', 'prepares', 'params'];
+  function fn(node_model) {
+    $.each(FIELDS, function(index, field) {
+      if (field in node_model && $.isArray(node_model[field])) {
+        node_model[field] = JSON.stringify(node_model[field]);
+      }
+    });
+  }
+  return fn;
+})();
+
 /**
  * Registry of models
  *  - Each model should have an ID attribute.
@@ -92,7 +106,7 @@ var ModalModule = function($, ko) {
   var module = function(modal, template) {
     var self = this;
 
-    self.modal = $(modal);
+    self.el = self.modal = $(modal);
     self.context = ko.observable();
     self.template = ko.observable(template || '');
     self.bound = false;
@@ -109,6 +123,12 @@ var ModalModule = function($, ko) {
     self.modal.modal('show');
   };
 
+  module.prototype.hide = function(context) {
+    var self = this;
+
+    self.el.modal('hide');
+  };
+
   module.prototype.setTemplate = function(template) {
     var self = this;
 
@@ -473,6 +493,21 @@ var NodeModule = function($, IdGeneratorTable) {
     self.children = ko.observableArray([]);
     self.model = model;
 
+    var errors = {};
+    for(var key in model) {
+      switch(key) {
+        case 'child_links':
+        case 'node_ptr':
+        case 'initialize':
+        case 'toString':
+        break;
+        default:
+          errors[key] = [];
+        break;
+      }
+    }
+    self.errors = ko.mapping.fromJS(errors);
+
     self.edit_template = model.node_type + 'EditTemplate';
     switch(model.node_type) {
     case 'start':
@@ -701,12 +736,28 @@ var NodeModule = function($, IdGeneratorTable) {
               });
             };
 
+            return map_params(options, subscribe);
+          },
+          update: function(options) {
+            var parent = options.parent;
+            var subscribe = function(mapping) {
+              mapping.name.subscribe(function(value) {
+                parent.job_properties.valueHasMutated();
+              });
+              mapping.value.subscribe(function(value) {
+                parent.job_properties.valueHasMutated();
+              });
+            };
+
             return map_params(options, subscribe);
           }
         },
         files: {
           create: function(options) {
             return map_params(options, function() {});
+          },
+          update: function(options) {
+            return map_params(options, function() {});
           }
         },
         archives: {
@@ -721,6 +772,19 @@ var NodeModule = function($, IdGeneratorTable) {
               });
             };
 
+            return map_params(options, subscribe);
+          },
+          update: function(options) {
+            var parent = options.parent;
+            var subscribe = function(mapping) {
+              mapping.name.subscribe(function(value) {
+                parent.archives.valueHasMutated();
+              });
+              mapping.dummy.subscribe(function(value) {
+                parent.archives.valueHasMutated();
+              });
+            };
+
             return map_params(options, subscribe);
           }
         },
@@ -736,6 +800,19 @@ var NodeModule = function($, IdGeneratorTable) {
               });
             };
 
+            return map_params(options, subscribe);
+          },
+          update: function(options) {
+            var parent = options.parent;
+            var subscribe = function(mapping) {
+              mapping.value.subscribe(function(value) {
+                parent.params.valueHasMutated();
+              });
+              mapping.type.subscribe(function(value) {
+                parent.params.valueHasMutated();
+              });
+            };
+
             return map_params(options, subscribe);
           }
         },
@@ -751,6 +828,19 @@ var NodeModule = function($, IdGeneratorTable) {
               });
             };
 
+            return map_params(options, subscribe);
+          },
+          update: function(options) {
+            var parent = options.parent;
+            var subscribe = function(mapping) {
+              mapping.value.subscribe(function(value) {
+                parent.prepares.valueHasMutated();
+              });
+              mapping.type.subscribe(function(value) {
+                parent.prepares.valueHasMutated();
+              });
+            };
+
             return map_params(options, subscribe);
           }
         }
@@ -779,6 +869,30 @@ var NodeModule = function($, IdGeneratorTable) {
 
     },
 
+    validate: function( ) {
+      var self = this;
+
+      var options = {};
+
+      data = $.extend(true, {}, self.model);
+
+      var success = false;
+      var request = $.extend({
+        url: '/oozie/workflows/' + self._workflow.id() + '/actions/' + self.node_type() + '/validate',
+        type: 'POST',
+        data: { node: JSON.stringify(data) },
+        success: function(data) {
+          ko.mapping.fromJS(data.data, self.errors);
+          success = data.status == 0;
+        },
+        async: false,
+      }, options);
+
+      $.ajax(request);
+
+      return success;
+    },
+
     // Hierarchy manipulation.
     /**
      * Append node to self
@@ -1322,6 +1436,17 @@ var WorkflowModule = function($, NodeModelChooser, Node, ForkNode, DecisionNode,
             parent.job_properties.valueHasMutated();
           });
           return mapping;
+        },
+        update: function(options) {
+          var mapping = ko.mapping.fromJS($.parseJSON(options.data) || options.data);
+          var parent = options.parent;
+          mapping.name.subscribe(function(value) {
+            parent.job_properties.valueHasMutated();
+          });
+          mapping.value.subscribe(function(value) {
+            parent.job_properties.valueHasMutated();
+          });
+          return mapping;
         }
       },
       parameters: {
@@ -1337,7 +1462,18 @@ var WorkflowModule = function($, NodeModelChooser, Node, ForkNode, DecisionNode,
             parent.parameters.valueHasMutated();
           });
           return mapping;
-        }
+        },
+        update: function(options) {
+          var mapping =  ko.mapping.fromJS($.parseJSON(options.data) || options.data);
+          var parent = options.parent;
+          mapping.name.subscribe(function(value) {
+            parent.parameters.valueHasMutated();
+          });
+          mapping.value.subscribe(function(value) {
+            parent.parameters.valueHasMutated();
+          });
+          return mapping;
+        },
       }
     });
 
@@ -1413,9 +1549,6 @@ var WorkflowModule = function($, NodeModelChooser, Node, ForkNode, DecisionNode,
               break;
             }
 
-            if ('main_class' in temp) {
-              console.log(temp.main_class());
-            }
             self.registry.add(temp.id(), temp);
           });
         }