Jelajahi Sumber

HUE-888 [oozie] New oozie front end

- managers for managing lists.
- Models for links, nodes, workflows.
- Grab workflow in a full dump from API when read.
- Send full dump of workflow to API when update.
- Models for nodes, workflows.
- Grab workflow in a full dump from API when read.
- Send full dump of workflow to API when update.
- Added models and model choosers
- Selected method of extension
- Selected method of mapping to models
- mapping ko module used to map JSON to a view model
- workflow editable on front end
- import not doable front end only
- remove import action
- Workflow edition and addition in front end
- Add drag n drop padding
- Forking is more stable
- Can drag and drop nodes onto links beneath forks and prepend to child list
- Added decision node
- Fixed a few test cases
- Skipping all action tests for editor
- Added test cases for saving workflow and fetching workflow data
- Fixed a few UI bugs
- Fixed copy_remote_dir to copy file as owner
abec 13 tahun lalu
induk
melakukan
b0854899d9
25 mengubah file dengan 4093 tambahan dan 1800 penghapusan
  1. 1 1
      apps/beeswax/beeswax_server.sh
  2. 160 0
      apps/oozie/src/oozie/decorators.py
  3. 270 0
      apps/oozie/src/oozie/migrations/0009_auto__add_decision.py
  4. 86 219
      apps/oozie/src/oozie/models.py
  5. 362 0
      apps/oozie/src/oozie/templates/editor/action_utils.mako
  6. 195 0
      apps/oozie/src/oozie/templates/editor/control_utils.mako
  7. 2263 94
      apps/oozie/src/oozie/templates/editor/edit_workflow.mako
  8. 0 504
      apps/oozie/src/oozie/templates/editor/edit_workflow_action.mako
  9. 0 123
      apps/oozie/src/oozie/templates/editor/edit_workflow_fork.mako
  10. 0 88
      apps/oozie/src/oozie/templates/editor/gen/workflow-graph-editable.xml.mako
  11. 0 71
      apps/oozie/src/oozie/templates/editor/gen/workflow-graph-readonly.xml.mako
  12. 48 0
      apps/oozie/src/oozie/templates/editor/workflow_utils.mako
  13. 2 1
      apps/oozie/src/oozie/templates/utils.inc.mako
  14. 375 413
      apps/oozie/src/oozie/tests.py
  15. 7 8
      apps/oozie/src/oozie/urls.py
  16. 44 0
      apps/oozie/src/oozie/utils.py
  17. 222 0
      apps/oozie/src/oozie/views/api.py
  18. 25 275
      apps/oozie/src/oozie/views/editor.py
  19. 5 0
      desktop/core/static/ext/js/jquery/plugins/jquery-ui-autocomplete-1.9.1.min.js
  20. 4 0
      desktop/core/static/ext/js/jquery/plugins/jquery-ui-draggable-droppable-sortable-1.8.23.min.js
  21. 20 0
      desktop/core/static/ext/js/knockout.mapping-2.3.2.js
  22. 1 2
      desktop/libs/hadoop/src/hadoop/fs/webhdfs.py
  23. 1 0
      desktop/libs/liboozie/src/liboozie/oozie_api.py
  24. 1 0
      desktop/libs/liboozie/src/liboozie/submittion.py
  25. 1 1
      desktop/libs/liboozie/src/liboozie/types.py

+ 1 - 1
apps/beeswax/beeswax_server.sh

@@ -50,7 +50,7 @@ BEESWAX_ROOT=$(dirname $0)
 BEESWAX_JAR=$BEESWAX_ROOT/java-lib/BeeswaxServer.jar
 HIVE_LIB=$HIVE_HOME/lib
 
-export HADOOP_CLASSPATH=$(find $HIVE_LIB -name "*.jar" | tr "\n" :)
+export HADOOP_CLASSPATH=$(find $HADOOP_HOME -name hue-plugins*.jar | tr "\n" :):$(find $HIVE_LIB -name "*.jar" | tr "\n" :)
 
 if [ -n "$HADOOP_EXTRA_CLASSPATH_STRING" ]; then
   export HADOOP_CLASSPATH=$HADOOP_CLASSPATH:$HADOOP_EXTRA_CLASSPATH_STRING

+ 160 - 0
apps/oozie/src/oozie/decorators.py

@@ -0,0 +1,160 @@
+#!/usr/bin/env python
+# 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 logging
+
+
+from django.core.urlresolvers import reverse
+from django.db.models import Q
+from django.forms.formsets import formset_factory
+from django.forms.models import inlineformset_factory, modelformset_factory
+from django.http import HttpResponse
+from django.shortcuts import redirect
+from django.utils.functional import curry, wraps
+from django.utils.translation import ugettext as _
+
+from desktop.lib.django_util import render, extract_field_data
+from desktop.lib.exceptions import PopupException
+from desktop.lib.rest.http_client import RestException
+from hadoop.fs.exceptions import WebHdfsException
+from jobsub.models import OozieDesign
+from liboozie.submittion import Submission
+
+from oozie.models import Job, Workflow, Node, Link, History, Coordinator,\
+  Mapreduce, Java, Streaming, Pig, Hive, Sqoop, Ssh, Shell, DistCp, Decision
+from oozie.forms import NodeForm, WorkflowForm, CoordinatorForm, DatasetForm,\
+  DefaultLinkForm, design_form_by_type, ImportJobsubDesignForm, ParameterForm
+
+LOG = logging.getLogger(__name__)
+
+
+def check_job_access_permission(exception_class=PopupException):
+  """
+  Decorator ensuring that the user has access to the workflow or coordinator.
+
+  Arg: 'workflow' or 'coordinator' id.
+  Return: the workflow of coordinator or raise an exception
+
+  Notice: its gets an id in input and returns the full object in output (not an id).
+  """
+  def inner(view_func):
+    def decorate(request, *args, **kwargs):
+      if 'workflow' in kwargs:
+        job_type = 'workflow'
+      else:
+        job_type = 'coordinator'
+
+      job = kwargs.get(job_type)
+      if job is not None:
+        job = Job.objects.is_accessible_or_exception(request, job, exception_class=exception_class)
+      kwargs[job_type] = job
+
+      return view_func(request, *args, **kwargs)
+    return wraps(view_func)(decorate)
+  return inner
+
+
+def check_job_edition_permission(authorize_get=False, exception_class=PopupException):
+  """
+  Decorator ensuring that the user has the permissions to modify a workflow or coordinator.
+
+  Need to appear below @check_job_access_permission
+  """
+  def inner(view_func):
+    def decorate(request, *args, **kwargs):
+      if 'workflow' in kwargs:
+        job_type = 'workflow'
+      else:
+        job_type = 'coordinator'
+
+      job = kwargs.get(job_type)
+      if job is not None and not (authorize_get and request.method == 'GET'):
+        Job.objects.can_edit_or_exception(request, job, exception_class=exception_class)
+
+      return view_func(request, *args, **kwargs)
+    return wraps(view_func)(decorate)
+  return inner
+
+
+def check_action_access_permission(view_func):
+  """
+  Decorator ensuring that the user has access to the workflow action.
+
+  Arg: 'workflow action' id.
+  Return: the workflow action or raise an exception
+
+  Notice: its gets an id in input and returns the full object in output (not an id).
+  """
+  def decorate(request, *args, **kwargs):
+    action_id = kwargs.get('action')
+    action = Node.objects.get(id=action_id).get_full_node()
+    Job.objects.is_accessible_or_exception(request, action.workflow.id)
+    kwargs['action'] = action
+
+    return view_func(request, *args, **kwargs)
+  return wraps(view_func)(decorate)
+
+
+def check_action_edition_permission(view_func):
+  """
+  Decorator ensuring that the user has the permissions to modify a workflow action.
+
+  Need to appear below @check_action_access_permission
+  """
+  def decorate(request, *args, **kwargs):
+    action = kwargs.get('action')
+    Job.objects.can_edit_or_exception(request, action.workflow)
+
+    return view_func(request, *args, **kwargs)
+  return wraps(view_func)(decorate)
+
+
+def check_dataset_access_permission(view_func):
+  """
+  Decorator ensuring that the user has access to dataset.
+
+  Arg: 'dataset'.
+  Return: the dataset or raise an exception
+
+  Notice: its gets an id in input and returns the full object in output (not an id).
+  """
+  def decorate(request, *args, **kwargs):
+    dataset = kwargs.get('dataset')
+    if dataset is not None:
+      dataset = Dataset.objects.is_accessible_or_exception(request, dataset)
+    kwargs['dataset'] = dataset
+
+    return view_func(request, *args, **kwargs)
+  return wraps(view_func)(decorate)
+
+
+def check_dataset_edition_permission(authorize_get=False):
+  """
+  Decorator ensuring that the user has the permissions to modify a dataset.
+  A dataset can be edited if the coordinator that owns the dataset can be edited.
+
+  Need to appear below @check_dataset_access_permission
+  """
+  def inner(view_func):
+    def decorate(request, *args, **kwargs):
+      dataset = kwargs.get('dataset')
+      if dataset is not None and not (authorize_get and request.method == 'GET'):
+        Job.objects.can_edit_or_exception(request, dataset.coordinator)
+
+      return view_func(request, *args, **kwargs)
+    return wraps(view_func)(decorate)
+  return inner

+ 270 - 0
apps/oozie/src/oozie/migrations/0009_auto__add_decision.py

@@ -0,0 +1,270 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+    
+    def forwards(self, orm):
+        
+        # Adding model 'Decision'
+        db.create_table('oozie_decision', (
+            ('node_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['oozie.Node'], unique=True, primary_key=True)),
+        ))
+        db.send_create_signal('oozie', ['Decision'])
+    
+    
+    def backwards(self, orm):
+        
+        # Deleting model 'Decision'
+        db.delete_table('oozie_decision')
+    
+    
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '80', 'unique': 'True'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'max_length': '30', 'unique': 'True'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'oozie.coordinator': {
+            'Meta': {'object_name': 'Coordinator', '_ormbases': ['oozie.Job']},
+            'concurrency': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'end': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 11, 3, 12, 54, 7, 295114)'}),
+            'execution': ('django.db.models.fields.CharField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
+            'frequency_number': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}),
+            'frequency_unit': ('django.db.models.fields.CharField', [], {'default': "'days'", 'max_length': '20'}),
+            'job_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Job']", 'unique': 'True', 'primary_key': 'True'}),
+            'start': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 10, 31, 12, 54, 7, 295060)'}),
+            'throttle': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'timeout': ('django.db.models.fields.SmallIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'timezone': ('django.db.models.fields.CharField', [], {'default': "'America/Los_Angeles'", 'max_length': '24'}),
+            'workflow': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Workflow']", 'null': 'True'})
+        },
+        'oozie.datainput': {
+            'Meta': {'object_name': 'DataInput'},
+            'coordinator': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Coordinator']"}),
+            'dataset': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Dataset']", 'unique': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'})
+        },
+        'oozie.dataoutput': {
+            'Meta': {'object_name': 'DataOutput'},
+            'coordinator': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Coordinator']"}),
+            'dataset': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Dataset']", 'unique': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'})
+        },
+        'oozie.dataset': {
+            'Meta': {'object_name': 'Dataset'},
+            'coordinator': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Coordinator']"}),
+            'description': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}),
+            'done_flag': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '64', 'blank': 'True'}),
+            'frequency_number': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}),
+            'frequency_unit': ('django.db.models.fields.CharField', [], {'default': "'days'", 'max_length': '20'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'start': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 10, 31, 12, 54, 7, 295858)'}),
+            'timezone': ('django.db.models.fields.CharField', [], {'default': "'America/Los_Angeles'", 'max_length': '24'}),
+            'uri': ('django.db.models.fields.CharField', [], {'default': "'/data/${YEAR}${MONTH}${DAY}'", 'max_length': '1024'})
+        },
+        'oozie.decision': {
+            'Meta': {'object_name': 'Decision'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.distcp': {
+            'Meta': {'object_name': 'DistCp'},
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.end': {
+            'Meta': {'object_name': 'End'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.fork': {
+            'Meta': {'object_name': 'Fork'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.history': {
+            'Meta': {'object_name': 'History'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'job': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Job']"}),
+            'oozie_job_id': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'properties': ('django.db.models.fields.TextField', [], {}),
+            'submission_date': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'submitter': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        'oozie.hive': {
+            'Meta': {'object_name': 'Hive'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': '\'[{"name":"oozie.hive.defaults","value":"hive-default.xml"}]\''}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'script_path': ('django.db.models.fields.CharField', [], {'max_length': '256'})
+        },
+        'oozie.java': {
+            'Meta': {'object_name': 'Java'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'args': ('django.db.models.fields.CharField', [], {'max_length': '4096', 'blank': 'True'}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'jar_path': ('django.db.models.fields.CharField', [], {'max_length': '512'}),
+            'java_opts': ('django.db.models.fields.CharField', [], {'max_length': '256', 'blank': 'True'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'main_class': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.job': {
+            'Meta': {'object_name': 'Job'},
+            'deployment_dir': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+            'description': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_shared': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True', 'blank': 'True'}),
+            'last_modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+            'parameters': ('django.db.models.fields.TextField', [], {'default': '\'[{"name":"oozie.use.system.libpath","value":"true"}]\''}),
+            'schema_version': ('django.db.models.fields.CharField', [], {'max_length': '128'})
+        },
+        'oozie.join': {
+            'Meta': {'object_name': 'Join'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.kill': {
+            'Meta': {'object_name': 'Kill'},
+            'message': ('django.db.models.fields.CharField', [], {'default': "'Action failed, error message[${wf:errorMessage(wf:lastErrorNode())}]'", 'max_length': '256'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'})
+        },
+        'oozie.link': {
+            'Meta': {'object_name': 'Link'},
+            'child': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'parent_node'", 'to': "orm['oozie.Node']"}),
+            'comment': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'parent': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'child_node'", 'to': "orm['oozie.Node']"})
+        },
+        'oozie.mapreduce': {
+            'Meta': {'object_name': 'Mapreduce'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'jar_path': ('django.db.models.fields.CharField', [], {'max_length': '512'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True'}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.node': {
+            'Meta': {'object_name': 'Node'},
+            'children': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'parents'", 'symmetrical': 'False', 'through': "orm['oozie.Link']", 'to': "orm['oozie.Node']"}),
+            'description': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'node_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
+            'workflow': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['oozie.Workflow']"})
+        },
+        'oozie.pig': {
+            'Meta': {'object_name': 'Pig'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'script_path': ('django.db.models.fields.CharField', [], {'max_length': '256'})
+        },
+        'oozie.shell': {
+            'Meta': {'object_name': 'Shell'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'capture_output': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'command': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"})
+        },
+        'oozie.sqoop': {
+            'Meta': {'object_name': 'Sqoop'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'prepares': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'script_path': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'})
+        },
+        'oozie.ssh': {
+            'Meta': {'object_name': 'Ssh'},
+            'capture_output': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'command': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'host': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'params': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'user': ('django.db.models.fields.CharField', [], {'max_length': '64'})
+        },
+        'oozie.start': {
+            'Meta': {'object_name': 'Start'},
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True'})
+        },
+        'oozie.streaming': {
+            'Meta': {'object_name': 'Streaming'},
+            'archives': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'files': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'mapper': ('django.db.models.fields.CharField', [], {'max_length': '512'}),
+            'node_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Node']", 'unique': 'True', 'primary_key': 'True'}),
+            'reducer': ('django.db.models.fields.CharField', [], {'max_length': '512'})
+        },
+        'oozie.workflow': {
+            'Meta': {'object_name': 'Workflow', '_ormbases': ['oozie.Job']},
+            'end': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'end_workflow'", 'blank': 'True', 'null': 'True', 'to': "orm['oozie.End']"}),
+            'is_single': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
+            'job_properties': ('django.db.models.fields.TextField', [], {'default': "'[]'"}),
+            'job_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['oozie.Job']", 'unique': 'True', 'primary_key': 'True'}),
+            'job_xml': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}),
+            'start': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'start_workflow'", 'blank': 'True', 'null': 'True', 'to': "orm['oozie.Start']"})
+        }
+    }
+    
+    complete_apps = ['oozie']

+ 86 - 219
apps/oozie/src/oozie/models.py

@@ -64,7 +64,7 @@ A Workflow/Coordinator can be modified only by its owner or a superuser.
 Permissions checking happens by adding the decorators.
 """
 class JobManager(models.Manager):
-  def is_accessible_or_exception(self, request, job_id):
+  def is_accessible_or_exception(self, request, job_id, exception_class=PopupException):
     if job_id is None:
       return
     try:
@@ -76,16 +76,16 @@ class JobManager(models.Manager):
             {'username': request.user.username, 'id': job.id}
         access_warn(request, message)
         request.error(message)
-        raise PopupException(message)
+        raise exception_class(message)
 
     except Job.DoesNotExist:
-      raise PopupException(_('job %(id)s not exist') % {'id': job_id})
+      raise exception_class(_('job %(id)s not exist') % {'id': job_id})
 
-  def can_edit_or_exception(self, request, job):
+  def can_edit_or_exception(self, request, job, exception_class=PopupException):
     if job.is_editable(request.user):
       return True
     else:
-      raise PopupException(_('Not allowed to modified this job'))
+      raise exception_class(_('Not allowed to modified this job'))
 
 
 class Job(models.Model):
@@ -243,177 +243,6 @@ class Workflow(Job):
   def get_properties(self):
     return json.loads(self.job_properties)
 
-  def add_action(self, action, parent_action_id):
-    parent = Node.objects.get(id=parent_action_id).get_full_node()
-    error = Kill.objects.get(name='kill', workflow=self)
-
-    if parent.node_type == 'start':
-      previous_to = parent.get_child('to')
-      to_link = parent.get_link('to')
-    elif parent.node_type == 'join':
-      previous_to = parent.get_child('to')
-      to_link = parent.get_link('to')
-    else:
-      previous_to = parent.get_child('ok')
-      to_link = parent.get_link('ok')
-
-    to_link.child = action
-    ok_link = Link(parent=action, child=previous_to, name='ok')
-    error_link = Link(parent=action, child=error, name='error')
-
-    to_link.save()
-    ok_link.save()
-    error_link.save()
-
-  def _add_to_fork(self, action, fork):
-    Link.objects.create(parent=fork, child=action, name='start')
-    join = fork.get_child_join()
-
-    ok = action.get_link('ok')
-    ok.child = join
-    ok.save()
-
-  def move_action_up(self, action):
-    parent = action.get_parent()
-    child = action.get_child('ok')
-
-    # Case add to an already existent fork
-    if parent.node_type == 'join':
-      # Get fork for parent
-      join = parent
-      fork = join.get_full_node().get_parent_fork()
-      self._add_to_fork(action, fork)
-      # Update join
-      to = join.get_link('to')
-      to.child = child
-      to.save()
-    # Case leaving a Fork
-    elif parent.node_type == 'fork':
-      parent_parent = parent.get_parent()
-      if parent_parent.node_type == 'start':
-        parent_parent_link = parent_parent.get_link('to')
-      else:
-        parent_parent_link = parent_parent.get_link('ok') # If fork of fork, need to use recursivity
-
-      fork = parent_parent_link.child
-      action_below = action.get_child('ok')
-      parent_link = action.get_parent_link()
-
-      parent_parent_link.child = action
-      parent_parent_link.save()
-
-      if action_below.node_type == 'join':
-        parent_link.delete()
-      else:
-        parent_link.child = action_below
-        parent_link.save()
-
-      ok = action.get_link('ok')
-      ok.child = fork
-      ok.save()
-
-      join = action
-    else:
-      # Case create a fork
-      if parent.node_type == 'fork': # No double forks yet        '
-        parent = parent.get_parent()
-        parent_parent = parent.get_parent()
-        if parent_parent.node_type == 'start':
-          parent_parent_link = parent_parent.get_link('to')
-        else:
-          parent_parent_link = parent_parent.get_link('ok')
-      else:
-        parent_parent_link = parent.get_parent_link()
-
-      fork = Fork.objects.create(workflow=action.workflow, node_type=Fork.node_type)
-      Link.objects.create(parent=fork, child=parent, name='start')
-      Link.objects.create(parent=fork, child=action, name='start')
-
-      join = Join.objects.create(workflow=action.workflow, node_type=Join.node_type)
-      Link.objects.create(parent=join, child=action.get_child('ok'), name='to')
-
-      Link.objects.create(parent=fork, child=join, name='related')
-
-      parent_parent_link.child = fork
-      parent_parent_link.save()
-
-      parent_link = parent.get_link('ok')
-      parent_link.child = join
-      parent_link.save()
-      action_link = action.get_link('ok')
-      action_link.child = join
-      action_link.save()
-
-    # Case leaving a fork
-    if child.node_type == 'join':
-      self._remove_fork(child.get_parent_fork())
-
-  def move_action_down(self, action):
-    below_action = action.get_child('ok')
-
-    if below_action.node_type == 'join':
-      parent_node = action.get_parent()
-      self._move_below(action, below_action)
-      self._remove_fork(parent_node)
-    elif below_action.node_type == 'fork':
-      parent_node = action.get_parent()
-      self._move_into_fork(action, below_action)
-      self._remove_fork(parent_node)
-    else:
-      self.move_action_up(below_action)
-
-  def delete_action(self, action):
-    parent_link = action.get_parent_link()
-
-    if action.get_parent().node_type != 'fork':
-      parent_link.child = action.get_child('ok')
-      parent_link.save()
-
-    action.delete()
-    self._remove_fork(parent_link.parent)
-
-  def _move_below(self, node, below):
-    above_link = node.get_parent_link()
-    node_link = node.get_child_link('ok')
-
-    if above_link.parent.node_type == 'fork':
-      above_link.delete()
-    else:
-      above_link.child = node.get_child('ok')
-      above_link.save()
-
-    below_link = below.get_child_link()
-    node_link.child = below_link.child
-    below_link.child = node
-    below_link.save()
-
-    node_link.save()
-
-  def _move_into_fork(self, node, fork):
-    above_link = node.get_parent_link()
-    above_link.child = fork
-    above_link.save()
-
-    self._add_to_fork(node, fork)
-
-  def _remove_fork(self, node):
-    if node.node_type == 'fork':
-      fork = node.get_full_node()
-      if len(fork.get_children()) <= 1:
-        join = fork.get_child_join()
-
-        self._remove_node(fork)
-        self._remove_node(join)
-
-  def _remove_node(self, node):
-    child = node.get_child()
-
-    for link in node.get_parent_links():
-      link.child = child
-      link.save()
-
-    node.delete()
-
   def clone(self, fs, new_owner=None):
     source_deployment_dir = self.deployment_dir # Needed
     nodes = self.node_set.all()
@@ -461,8 +290,10 @@ class Workflow(Job):
       LOG.error(msg)
       raise PopupException(msg)
 
-    return copy
+    # Reload workflow from DB... clears relationship cache
+    copy = Workflow.objects.get(id=copy.id)
 
+    return copy
 
   def has_cycle(self):
     """
@@ -531,36 +362,40 @@ class Workflow(Job):
 
   def get_hierarchy(self):
     node = self.start
-    return self.get_hierarchy_rec(node) + [[Kill.objects.get(name='kill', workflow=node.workflow)],
+    return self.get_hierarchy_rec(node=node) + [[Kill.objects.get(name='kill', workflow=node.workflow)],
                                            [End.objects.get(name='end', workflow=node.workflow)]]
 
-  def get_hierarchy_rec(self, node=None):
+  def get_hierarchy_rec(self, node=None, skip_parents_check=False):
     if node is None:
       node = self.start
       if node.id is None:
         return []
 
     node = node.get_full_node()
+    parents = node.get_parents()
+
+    if len(parents) > 1 and not skip_parents_check:
+      return []
 
     if isinstance(node, End):
       return [] # Not returning the end node
-    elif isinstance(node, Fork) and node.has_decisions():
+    elif isinstance(node, Decision):
       children = node.get_children('start')
-      return [[node] + [[self.get_hierarchy_rec(child) for child in children]]]
+      end = node.get_child_end_or_none()
+      if end:
+        return [[node, [self.get_hierarchy_rec(node=child) for child in children]] + self.get_hierarchy_rec(node=end, skip_parents_check=True)]
+      else:
+        return [[node, [self.get_hierarchy_rec(node=child) for child in children]]]
     elif isinstance(node, Fork):
       children = node.get_children('start')
-      return [[node] + [[self.get_hierarchy_rec(child) for child in children],
+      return [[node] + [[self.get_hierarchy_rec(node=child) for child in children],
                         node.get_child_join()]] + self.get_hierarchy_rec(node.get_child_join().get_child('to'))
     elif isinstance(node, Join):
       return []
     else:
-      child = Link.objects.filter(parent=node).exclude(name__in=['related', 'kill'])[0].child
+      child = Link.objects.filter(parent=node).exclude(name__in=['related', 'kill', 'error'])[0].child
       return [node] + self.get_hierarchy_rec(child)
 
-  def gen_graph(self, forms, template='editor/gen/workflow-graph-editable.xml.mako'):
-    index = dict([(form.instance.id, form) for form in forms])
-    return django_mako.render_to_string(template, {'nodes': self.get_hierarchy(), 'index': index})
-
   def gen_status_graph(self, forms, actions):
     template='editor/gen/workflow-graph-status.xml.mako'
 
@@ -576,7 +411,7 @@ class Workflow(Job):
 
 class Link(models.Model):
   # Links to exclude when using get_children_link(), get_parent_links() in the API
-  META_LINKS = ('related', 'default')
+  META_LINKS = ('related')
 
   parent = models.ForeignKey('Node', related_name='child_node')
   child = models.ForeignKey('Node', related_name='parent_node', verbose_name='')
@@ -639,8 +474,8 @@ class Node(models.Model):
       node = self.kill
     elif self.node_type == Fork.node_type:
       node = self.fork
-    elif self.node_type == Fork.ACTION_DECISION_TYPE:
-      node = self.fork
+    elif self.node_type == Decision.node_type:
+      node = self.decision
     elif self.node_type == Join.node_type:
       node = self.join
     else:
@@ -710,6 +545,9 @@ class Node(models.Model):
     else:
       return Link.objects.exclude(name__in=Link.META_LINKS).filter(parent=self, name=name)
 
+  def get_all_children_links(self):
+    return Link.objects.filter(parent=self)
+
   def get_template_name(self):
     return 'editor/gen/workflow-%s.xml.mako' % self.node_type
 
@@ -727,23 +565,6 @@ class Node(models.Model):
   def is_editable(self):
     return False
 
-  def can_move(self):
-    return True
-
-  def can_move_up(self):
-    parents = self.get_parents()
-    for parent in parents:
-      if parent.node_type == 'start':
-        return False
-    return parents and self.can_move()
-
-  def can_move_down(self):
-    children = self.get_children()
-    for child in children:
-      if child.node_type == 'end':
-        return False
-    return children and self.can_move()
-
 
 class Action(Node):
   """
@@ -758,9 +579,6 @@ class Action(Node):
   def is_editable(self):
     return True
 
-  def get_edit_link(self):
-    return reverse('oozie:edit_action', kwargs={'action': self.id})
-
 # The fields with '[]' as default value are JSON dictionaries
 # When adding a new action, also update
 #  - Action.types below
@@ -1123,10 +941,6 @@ class Fork(ControlFlow):
   A Fork can be converted into a Decision node.
   """
   node_type = 'fork'
-  ACTION_DECISION_TYPE = 'decision'
-
-  def has_decisions(self):
-    return self.get_children_links().exclude(comment='').exists()
 
   def is_visible(self):
     return True
@@ -1137,13 +951,27 @@ class Fork(ControlFlow):
   def is_editable(self):
     return False
 
-  def get_edit_link(self):
-    return reverse('oozie:edit_workflow_fork', kwargs={'action': self.id})
-
   def convert_to_decision(self):
     self.remove_join()
-    self.node_type = Fork.ACTION_DECISION_TYPE
-    self.save()
+
+    decision = Decision.objects.create(workflow=self.workflow, node_type=Decision.node_type)
+    decision.save()
+
+    links = self.get_all_children_links()
+    has_default = False
+    for link in links:
+      if link.name == 'default':
+        has_default = True
+        link.parent = decision
+
+    # Defaults to end
+    if not has_default:
+      link = Links.objects.create(name="default", parent=decision, child=self.workflow.end)
+      link.save()
+
+    self.delete()
+
+    return decision
 
   def update_description(self):
     self.description = ', '.join(self.get_children_links().values_list('comment', flat=True))
@@ -1162,6 +990,26 @@ class Fork(ControlFlow):
     join.delete()
 
 
+class Decision(ControlFlow):
+  node_type = 'decision'
+
+  def get_child_end_or_none(self):
+    try:
+      return Link.objects.get(parent=self, name='related').child.get_full_node()
+    except Link.DoesNotExist:
+      return None
+
+  def is_visible(self):
+    return True
+
+  def is_editable(self):
+    return False
+
+  def update_description(self):
+    self.description = ', '.join(self.get_children_links().values_list('comment', flat=True))
+    self.save()
+
+
 class Join(ControlFlow):
   node_type = 'join'
 
@@ -1514,3 +1362,22 @@ _STD_PROPERTIES = [
 
 _STD_PROPERTIES_JSON = json.dumps(_STD_PROPERTIES)
 
+ACTION_TYPES = {
+  Mapreduce.node_type: Mapreduce,
+  Streaming.node_type: Streaming,
+  Java.node_type: Java,
+  Pig.node_type: Pig,
+  Hive.node_type: Hive,
+  Sqoop.node_type: Sqoop,
+  Ssh.node_type: Ssh,
+  Shell.node_type: Shell,
+  DistCp.node_type: DistCp,
+}
+NODE_TYPES = ACTION_TYPES.copy()
+NODE_TYPES.update({
+  Fork.node_type: Fork,
+  Join.node_type: Join,
+  Decision.node_type: Decision,
+  Start.node_type: Start,
+  End.node_type: End,
+})

+ 362 - 0
apps/oozie/src/oozie/templates/editor/action_utils.mako

@@ -0,0 +1,362 @@
+## 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.
+
+<%!
+  from django.utils.translation import ugettext as _
+%>
+
+<%namespace name="utils" file="../utils.inc.mako" />
+
+
+<%def name="action_form(action_form, node_type, template=True)">
+% if template:
+  <script type="text/html" id="${node_type}EditTemplate">
+% endif
+  <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>
+        <h3 class="message" data-bind="text: '${_('Edit Node: ')}' + name()"></h3>
+      </div>
+
+      <div class="modal-content">
+        <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}) }
+            % endif
+          % endfor
+
+          ${ utils.render_constant(_('Action type'), node_type) }
+
+          <hr/>
+
+          <div class="control-group">
+            <label class="control-label"></label>
+            <div class="controls">
+            <p class="alert alert-info span5">
+              ${ _('All the paths are relative to the deployment directory. They can be absolute but this is not recommended.') }
+              <br/>
+              ${ _('You can parameterize values using case sensitive') } <code>${"${"}PARAMETER}</code>.
+            </p>
+            % if node_type == 'ssh':
+              <p class="alert alert-warn span5">
+                ${ _('The ssh server requires passwordless login') }.
+              </p>
+            % endif
+            </div>
+          </div>
+
+          % for field in action_form:
+            % if field.html_name not in ('name', 'description', 'node_type', 'job_xml'):
+              ${ utils.render_field(field) }
+            % endif
+          % endfor
+
+          % if 'prepares' in action_form.fields:
+            <%
+              prepares_field(action_form['prepares'], {
+                'name': 'prepares',
+                'add': [
+                  {'label': _('Add delete'), 'method': 'addPrepareDelete'},
+                  {'label': _('Add mkdir'), 'method': 'addPrepareMkdir'},
+                ],
+                'remove': '$parent.removePrepare'
+              })
+            %>
+          % endif
+
+          % if 'params' in action_form.fields:
+            % if node_type == 'pig':
+              <%
+              params_field(action_form['params'], {
+                'name': 'params',
+                'add': [
+                  {'label': _('Add Param'), 'method': 'addParam'},
+                  {'label': _('Add Argument'), 'method': 'addArgument'},
+                ],
+                'remove': '$parent.removeParam'
+              })
+              %>
+            % endif
+
+            % if node_type == 'shell':
+              <%
+              params_field(action_form['params'], {
+                'name': 'params',
+                'add': [
+                  {'label': _('Add Argument'), 'method': 'addArgument'},
+                  {'label': _('Add Env-Var'), 'method': 'addEnvVar'},
+                ],
+                'remove': '$parent.removeParam'
+              })
+              %>
+            % endif
+
+            % if node_type == 'hive':
+              <%
+              params_field(action_form['params'], {
+                'name': 'params',
+                'add': [
+                  {'label': _('Add Param'), 'method': 'addParam'},
+                ],
+                'remove': '$parent.removeParam'
+              })
+              %>
+            % endif
+
+            % if node_type == 'distcp':
+              <%
+              params_field(action_form['params'], {
+                'name': 'params',
+                'add': [
+                  {'label': _('Add Argument'), 'method': 'addArgument'},
+                ],
+                'remove': '$parent.removeParam'
+              })
+              %>
+            % endif
+
+            % if node_type in ('sqoop', 'ssh'):
+              <%
+              params_field(action_form['params'], {
+                'name': 'params',
+                'add': [
+                  {'label': _('Add Arg'), 'method': 'addArg'},
+                ],
+                'remove': '$parent.removeParam'
+              })
+              %>
+            % endif
+          % endif
+
+          % if 'job_properties' in action_form.fields:
+            <%
+            job_properties_field(action_form['job_properties'], {
+              'name': 'job_properties',
+              'add': 'addProp',
+              'remove': '$parent.removeProp'
+            })
+            %>
+          % endif
+
+          % if 'files' in action_form.fields:
+            <%
+            file_field(action_form['files'], {
+              'name': 'files',
+              'add': 'addFile',
+              'remove': '$parent.removeFile'
+            })
+            %>
+          % endif
+
+          % if 'archives' in action_form.fields:
+            <%
+            archives_field(action_form['archives'], {
+              'name': 'archives',
+              'add': 'addArchive',
+              'remove': '$parent.removeArchive'
+            })
+            %>
+          % endif
+
+          % if 'job_xml' in action_form.fields:
+            ${ utils.render_field(action_form['job_xml']) }
+          % endif
+
+        </fieldset>
+      </div>
+
+      <div class="modal-footer">
+        <button data-dismiss="modal" class="btn btn-primary">Done</button>
+      </div>
+
+    </form>
+  </div>
+% if template: 
+  </script>
+% endif
+</%def>
+
+<%def name="file_field(field, javascript_attrs={})">
+<div class="control-group" rel="popover" data-original-title="${ field.label }" data-content="${ field.help_text }">
+  <label class="control-label">${ _('Files') }</label>
+  <div class="controls">
+    % if 'name' in javascript_attrs:
+      <table class="table-condensed designTable" data-bind="visible: ${ javascript_attrs['name'] }().length > 0">
+        <tbody data-bind="foreach: ${ javascript_attrs['name'] }">
+          <tr>
+            <td>
+              <input type="text" class="span5 required"data-bind="fileChooser: $data, value: name, uniqueName: false" />
+            </td>
+            <td>
+              % if 'remove' in javascript_attrs:
+                <a class="btn" href="#" data-bind="click: ${ javascript_attrs['remove'] }">${ _('Delete') }</a>
+              % endif
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      % if 'add' in javascript_attrs:
+        <button class="btn" data-bind="click: ${ javascript_attrs['add'] }">${ _('Add File') }</button>
+      % endif
+    % endif
+  </div>
+</div>
+</%def>
+
+<%def name="archives_field(field, javascript_attrs={})">
+<div class="control-group" rel="popover" data-original-title="${ field.label }" data-content="${ field.help_text }">
+  <label class="control-label">${ _('Archives') }</label>
+  <div class="controls">
+    % if 'name' in javascript_attrs:
+      <table class="table-condensed designTable" data-bind="visible: ${ javascript_attrs['name'] }().length > 0">
+        <tbody data-bind="foreach: ${ javascript_attrs['name'] }">
+          <tr>
+            <td>
+              <input type="text" class="span5 required"data-bind="fileChooser: $data, value: name, uniqueName: false" />
+            </td>
+            <td>
+              % if 'remove' in javascript_attrs:
+                <a class="btn" href="#" data-bind="click: ${ javascript_attrs['remove'] }">${ _('Delete') }</a>
+              % endif
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      % if 'add' in javascript_attrs:
+        <button class="btn" data-bind="click: ${ javascript_attrs['add'] }">${ _('Add Archive') }</button>
+      % endif
+    % endif
+  </div>
+</div>
+</%def>
+
+<%def name="job_properties_field(field, javascript_attrs={})">
+<div class="control-group" rel="popover" data-original-title="${ field.label }" data-content="${ field.label }">
+  <label class="control-label">${ _('Job Properties') }</label>
+  <div class="controls">
+    % if 'name' in javascript_attrs:
+      <table class="table-condensed designTable" data-bind="visible: ${ javascript_attrs['name'] }().length > 0">
+        <thead>
+          <tr>
+            <th>${ _('Property name') }</th>
+            <th>${ _('Value') }</th>
+            <th/>
+          </tr>
+        </thead>
+        <tbody data-bind="foreach: ${ javascript_attrs['name'] }">
+          <tr>
+            <td><input type="text" class="span4 required propKey" data-bind="value: name, uniqueName: false" /></td>
+            <td><input type="text" class="span4 required" data-bind="fileChooser: $data, value: value, uniqueName: false" /></td>
+            <td>
+            % if 'remove' in javascript_attrs:
+              <a class="btn" href="#" data-bind="click: ${ javascript_attrs['remove'] }">${ _('Delete') }</a>
+            % endif
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      % if 'add' in javascript_attrs:
+        <button class="btn" data-bind="click: ${ javascript_attrs['add'] }">${ _('Add Property') }</button>
+      % endif
+    % endif
+  </div>
+</div>
+</%def>
+
+<%def name="params_field(field, javascript_attrs={})">
+<div class="control-group" rel="popover" data-original-title="${ field.label }" data-content="${ field.help_text }">
+  <label class="control-label">${ _('Params') }</label>
+  <div class="controls">
+    % if 'name' in javascript_attrs:
+      <table class="table-condensed designTable" data-bind="visible: ${ javascript_attrs['name'] }().length > 0">
+        <thead>
+          <tr>
+            <th>${ _('Type') }</th>
+            <th>${ _('Value') }</th>
+            <th/>
+          </tr>
+        </thead>
+        <tbody data-bind="foreach: ${ javascript_attrs['name'] }">
+          <tr>
+            <td>
+              <span class="span4 required" data-bind="text: type" />
+            </td>
+            <td>
+              <input type="text" class="input span4 required" data-bind="fileChooser: $data, value: value, uniqueName: false" />
+            </td>
+            <td>
+            % if 'remove' in javascript_attrs:
+              <a class="btn" href="#" data-bind="click: ${ javascript_attrs['remove'] }">${ _('Delete') }</a>
+            % endif
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      % if 'add' in javascript_attrs:
+        % for method in javascript_attrs['add']:
+          <button class="btn" data-bind="click: ${ method['method'] }">${ _(method['label']) }</button>
+        % endfor
+      % endif
+    % endif
+  </div>
+</div>
+</%def>
+
+<%def name="prepares_field(field, javascript_attrs={})">
+<div class="control-group" rel="popover" data-original-title="${ field.label }" data-content="${ field.help_text }">
+  <label class="control-label">${ _('Prepare') }</label>
+  <div class="controls">
+    % if 'name' in javascript_attrs:
+      <table class="table-condensed designTable" data-bind="visible: ${ javascript_attrs['name'] }().length > 0">
+        <thead>
+          <tr>
+            <th>${ _('Type') }</th>
+            <th>${ _('Value') }</th>
+            <th/>
+          </tr>
+        </thead>
+        <tbody data-bind="foreach: ${ javascript_attrs['name'] }">
+          <tr>
+            <td>
+              <span class="span4 required" data-bind="text: type" />
+            </td>
+            <td>
+              <input type="text" class="input span4 required" data-bind="fileChooser: $data, value: value, uniqueName: false" />
+            </td>
+            <td>
+              % if 'remove' in javascript_attrs:
+                <a class="btn" href="#" data-bind="click: ${ javascript_attrs['remove'] }">${ _('Delete') }</a>
+              % endif
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      % if 'add' in javascript_attrs:
+        % for method in javascript_attrs['add']:
+          <button class="btn" data-bind="click: ${ method['method'] }">${ _(method['label']) }</button>
+        % endfor
+      % endif
+    % endif
+  </div>
+</div>
+</%def>

+ 195 - 0
apps/oozie/src/oozie/templates/editor/control_utils.mako

@@ -0,0 +1,195 @@
+## 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.
+
+<%!
+  from django.utils.translation import ugettext as _
+%>
+
+<%namespace name="utils" file="../utils.inc.mako" />
+
+
+
+
+<%def name="fork_form(node_type, template=True, javascript_attrs={})">
+% if template:
+  <script type="text/html" id="${node_type}EditTemplate">
+% endif
+  <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>
+        <h3 class="message" data-bind="text: '${_('Edit Node: ')}' + name()"></h3>
+      </div>
+
+      <div class="modal-content">
+        <div class="alert alert-info">
+          ${ _('Convert this fork into a decision?') }
+        </div>
+      </div>
+
+      <div class="modal-footer">
+        <button data-dismiss="modal" class="btn">No</button>
+        % if 'convert' in javascript_attrs:
+          <button data-dismiss="modal" class="btn btn-primary" data-bind="click: ${ javascript_attrs['convert'] }">${ _('Yes') }</button>
+        % endif
+      </div>
+
+    </form>
+  </div>
+
+% if template:
+  </script>
+% endif
+</%def>
+
+
+
+
+
+<%def name="decision_form(link_form, default_link_form, node_type, template=True, javascript_attrs={})">
+% if template:
+  <script type="text/html" id="${node_type}EditTemplate">
+% endif
+  <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>
+        <h3 class="message" data-bind="text: '${_('Edit Node: ')}' + name()"></h3>
+      </div>
+
+      <div class="modal-content">
+        <fieldset>
+
+          <div class="control-group">
+            <label class="control-label"></label>
+            <div class="controls span8">
+              <div>${ _('Examples of predicates:') }</div>
+              <div class="well">
+                ${"${"} fs:fileSize(secondjobOutputDir) gt 10 * GB }
+                <br/>
+                ${"${"} hadoop:counters('secondjob')[RECORDS][REDUCE_OUT] lt 1000000 }
+              </div>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <label class="control-label"></label>
+            <div class="controls">
+              <table class="table-condensed">
+                <thead>
+                  <tr>
+                    <th>${ _('Predicate') }</th>
+                    <th/>
+                    <th>${ _('Action') }</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <!-- ko foreach: links() -->
+                  <tr>
+                    <td>
+                      ${ utils.render_field(link_form['comment'], extra_attrs={'data-bind': 'value: comment'}) }
+                    </td>
+                    <td class="center">
+                      ${ _('go to') }
+                    </td>
+                    <td class="right">
+                      % if 'name' in javascript_attrs:
+                        <a class="span3" data-bind="text: ${ javascript_attrs['name'] }"></a>
+                      % endif
+                    </td>
+                  </tr>
+                  <!-- /ko -->
+
+                  <!-- ko foreach: meta_links() -->
+                    <!-- ko if: $data.name() == 'default' -->
+                    <tr>
+                      <td>
+                       <div class="control-group">
+                          <label class="control-label"></label>
+                          <div class="controls span8">
+                            <div>${ _('default') }</div>
+                          </div>
+                        </div>
+                      </td>
+                      <td class="center">
+                        ${ _('go to') }
+                      </td>
+                      <td class="right">
+                        ${ utils.render_field(default_link_form['child'], extra_attrs={'data-bind': 'value: child'}) }
+                      </td>
+                    </tr>
+                    <!-- /ko -->
+                  <!-- /ko -->
+                </tbody>
+              </table>
+            </div>
+          </div>
+
+        </fieldset>
+      </div>
+
+      <div class="modal-footer">
+        <button data-dismiss="modal" class="btn btn-primary">Done</button>
+      </div>
+
+    </form>
+  </div>
+
+% if template: 
+  </script>
+% endif
+</%def>
+
+<%def name="links_form_fields(link_form, default_link_form, javascript_attrs={})">
+
+<div class="control-group" rel="popover" data-original-title="${ field.label }" data-content="${ field.help_text }">
+  <label class="control-label">${ _('Params') }</label>
+  <div class="controls">
+    % if 'name' in javascript_attrs:
+      <table class="table-condensed designTable" data-bind="visible: ${ javascript_attrs['name'] }().length > 0">
+        <thead>
+          <tr>
+            <th>${ _('Type') }</th>
+            <th>${ _('Value') }</th>
+            <th/>
+          </tr>
+        </thead>
+        <tbody data-bind="foreach: ${ javascript_attrs['name'] }">
+          <tr>
+            <td>
+              <span class="span4 required" data-bind="text: type" />
+            </td>
+            <td>
+              <input type="text" class="input span4 required" data-bind="fileChooser: $data, value: value, uniqueName: false" />
+            </td>
+            <td>
+            % if 'remove' in javascript_attrs:
+              <a class="btn" href="#" data-bind="click: ${ javascript_attrs['remove'] }">${ _('Delete') }</a>
+            % endif
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      % if 'add' in javascript_attrs:
+        % for method in javascript_attrs['add']:
+          <button class="btn" data-bind="click: ${ method['method'] }">${ _(method['label']) }</button>
+        % endfor
+      % endif
+    % endif
+  </div>
+</div>
+</%def>

+ 2263 - 94
apps/oozie/src/oozie/templates/editor/edit_workflow.mako

@@ -21,21 +21,23 @@
 
 <%namespace name="layout" file="../navigation-bar.mako" />
 <%namespace name="utils" file="../utils.inc.mako" />
-<%namespace name="properties" file="job_action_properties.mako" />
+<%namespace name="actions" file="action_utils.mako" />
+<%namespace name="controls" file="control_utils.mako" />
+<%namespace name="workflows" file="workflow_utils.mako" />
 
 ${ commonheader(_("Oozie App"), "oozie", user, "100px") }
 ${ layout.menubar(section='workflows') }
 
 
-<div class="container-fluid">
-  <h1>${ _('Workflow') } ${ workflow.name }</h1>
+<div id="workflow" class="container-fluid">
+  <h1 data-bind="text: '${ _('Workflow') } ' + name()"></h1>
 
   <div class="well">
-    ${ _('Description:') } ${ workflow.description or "N/A" }
+    <span data-bind="text: '${ _('Description') }: ' + description() || 'N/A'"></span>
     <div class="pull-right" style="margin-top:-5px">
       % if user_can_edit_job:
         <label>
-            <a href="/filebrowser/view${ workflow.deployment_dir }" class="btn">
+            <a data-bind="attrs: { href: '/filebrowser/view' + deployment_dir() }" class="btn">
               ${ _('Upload') }
             </a>
             ${ _('files to deployment directory') }
@@ -52,7 +54,7 @@ ${ layout.menubar(section='workflows') }
     % endif
   </ul>
 
-  <form class="form-horizontal" id="jobForm" action="${ url('oozie:edit_workflow', workflow=workflow.id) }" method="POST">
+  <form class="form-horizontal" id="jobForm" method="POST">
 
     <div class="tab-content">
       <div class="tab-pane active" id="editor">
@@ -65,87 +67,72 @@ ${ layout.menubar(section='workflows') }
               <li class="active">
                 <a href="#add" data-toggle="tab">${ _('Add') }</a>
               </li>
-              <li>
-                <a href="#import" data-toggle="tab">${ _('Import') }</a>
-              </li>
              </ul>
 
             <div class="tab-content">
               <div class="tab-pane active" id="add">
                 <p>
-                <a href="${ url('oozie:new_action', workflow=workflow.id, node_type='mapreduce', parent_action_id=workflow.end.get_parents()[0].id) }"
-                  title="${ _('Click to add to the end') }" class="btn">
+                <a data-node-type="mapreduce"
+                  title="${ _('Click to add to the end') }" class="btn new-node-link">
                   <i class="icon-plus"></i> ${ _('MapReduce') }
                 </a>
                 <p/>
                 <p>
-                <a href="${ url('oozie:new_action', workflow=workflow.id, node_type='streaming', parent_action_id=workflow.end.get_parents()[0].id) }"
-                  title="${ _('Click to add to the end') }" class="btn">
+                <a data-node-type="streaming"
+                  title="${ _('Click to add to the end') }" class="btn new-node-link">
                   <i class="icon-plus"></i> ${ _('Streaming') }
                 </a>
                 <p/>
                 <p>
-                <a href="${ url('oozie:new_action', workflow=workflow.id, node_type='java', parent_action_id=workflow.end.get_parents()[0].id) }"
-                  title="${ _('Click to add to the end') }" class="btn">
+                <a data-node-type="java"
+                  title="${ _('Click to add to the end') }" class="btn new-node-link">
                   <i class="icon-plus"></i> ${ _('Java') }
                 </a>
                 <p/>
                 <p>
-                <a href="${ url('oozie:new_action', workflow=workflow.id, node_type='pig', parent_action_id=workflow.end.get_parents()[0].id) }"
-                  title="${ _('Click to add to the end') }" class="btn">
+                <a data-node-type="pig"
+                  title="${ _('Click to add to the end') }" class="btn new-node-link">
                   <i class="icon-plus"></i> ${ _('Pig') }
                 </a>
                 <p/>
                 <p>
-                <a href="${ url('oozie:new_action', workflow=workflow.id, node_type='hive', parent_action_id=workflow.end.get_parents()[0].id) }"
-                  title="${ _('Click to add to the end') }" class="btn">
+                <a data-node-type="hive"
+                  title="${ _('Click to add to the end') }" class="btn new-node-link">
                   <i class="icon-plus"></i> ${ _('Hive') }
                 </a>
                 <p/>
                 <p>
-                <a href="${ url('oozie:new_action', workflow=workflow.id, node_type='sqoop', parent_action_id=workflow.end.get_parents()[0].id) }"
-                  title="${ _('Click to add to the end') }" class="btn">
+                <a data-node-type="sqoop"
+                  title="${ _('Click to add to the end') }" class="btn new-node-link">
                   <i class="icon-plus"></i> ${ _('Sqoop') }
                 </a>
                 <p/>
                 <p>
-                <a href="${ url('oozie:new_action', workflow=workflow.id, node_type='shell', parent_action_id=workflow.end.get_parents()[0].id) }"
-                  title="${ _('Click to add to the end') }" class="btn">
+                <a data-node-type="shell"
+                  title="${ _('Click to add to the end') }" class="btn new-node-link">
                   <i class="icon-plus"></i> ${ _('Shell') }
                 </a>
                 <p/>
                 <p>
-                <a href="${ url('oozie:new_action', workflow=workflow.id, node_type='ssh', parent_action_id=workflow.end.get_parents()[0].id) }"
-                  title="${ _('Click to add to the end') }" class="btn">
+                <a data-node-type="ssh"
+                  title="${ _('Click to add to the end') }" class="btn new-node-link">
                   <i class="icon-plus"></i> ${ _('Ssh') }
                 </a>
                 <p/>
                 <p>
-                <a href="${ url('oozie:new_action', workflow=workflow.id, node_type='distcp', parent_action_id=workflow.end.get_parents()[0].id) }"
-                  title="${ _('Click to add to the end') }" class="btn">
+                <a data-node-type="distcp"
+                  title="${ _('Click to add to the end') }" class="btn new-node-link">
                   <i class="icon-plus"></i> ${ _('DistCp') }
                 </a>
                 <p/>
               </div>
-              <div class="tab-pane" id="import">
-                <p>
-                <a href="javascript:void(0);" title="${ _('Click to add to the end') }" class="btn modalWindow"
-                  data-modal-url="${ url('oozie:import_action', workflow=workflow.id, parent_action_id=workflow.end.get_parents()[0].id) }"
-                  data-modal-id="#modal-window">
-                  <i class="icon-plus"></i> ${ _('Job Design') }
-                </a>
-                <p/>
-              </div>
             </div>
-            % endif
+             % endif
           </div>
 
           <div class="span9">
             <h2>${ _('Flow') }</h2>
             <br/>
-
-            ${ actions_formset.management_form }
-
             <hr/>
 
             % if workflow.node_set.count() == 3:
@@ -154,11 +141,12 @@ ${ layout.menubar(section='workflows') }
               </div>
             % endif
 
-            ${ graph }
+            <div id="graph" class="row-fluid" data-bind="template: { name: function(item) { return item.view_template() }, foreach: nodes }"></div>
           </div>
         </div>
         <div class="form-actions center">
           <a href="${ url('oozie:list_workflows') }" class="btn">${ _('Back') }</a>
+          <a data-bind="click: function() { save() }" href="javascript:void(0);" class="btn btn-primary">${ _('Save') }</a>
         </div>
       </div>
 
@@ -169,9 +157,9 @@ ${ layout.menubar(section='workflows') }
             <h2>${ _('Properties') }</h2>
             <br/>
               <fieldset>
-                ${ utils.render_field(workflow_form['name']) }
-                ${ utils.render_field(workflow_form['description']) }
-                ${ utils.render_field(workflow_form['is_shared']) }
+                ${ utils.render_field(workflow_form['name'], extra_attrs={'data-bind': 'value: %s' % workflow_form['name'].name}) }
+                ${ utils.render_field(workflow_form['description'], extra_attrs={'data-bind': 'value: %s' % workflow_form['description'].name}) }
+                ${ utils.render_field(workflow_form['is_shared'], extra_attrs={'data-bind': 'value: %s' % workflow_form['is_shared'].name}) }
 
                 <div class="control-group ">
                   <label class="control-label">
@@ -183,17 +171,27 @@ ${ layout.menubar(section='workflows') }
 
                <div id="advanced-container" class="hide">
                  % if user_can_edit_job:
-                   ${ utils.render_field(workflow_form['deployment_dir']) }
+                  ${ utils.render_field(workflow_form['deployment_dir'], extra_attrs={'data-bind': 'value: %s' % workflow_form['deployment_dir'].name}) }
                  % endif
-                 ## to remove
-                 ${ properties.print_key_value(workflow_form['parameters'], 'parameters', parameters) }
-                 ${ workflow_form['parameters'] }
-                 ## to remove
-                 ${ properties.print_key_value(workflow_form['job_properties'], 'job_properties', job_properties) }
-                 ${ workflow_form['job_properties'] }
-                 ${ workflow_form['schema_version'] }
+
+                 <%
+                 workflows.key_value_field(workflow_form['parameters'], {
+                  'name': 'parameters',
+                  'remove': '$root.removeParameter',
+                  'add': '$root.addParameter',
+                 })
+                 %>
+
+                 <%
+                 workflows.key_value_field(workflow_form['job_properties'], {
+                  'name': 'job_properties',
+                  'remove': '$root.removeJobProperty',
+                  'add': '$root.addJobProperty',
+                 })
+                 %>
+
                  % if user_can_edit_job:
-                   ${ utils.render_field(workflow_form['job_xml']) }
+                  ${ utils.render_field(workflow_form['job_xml'], extra_attrs={'data-bind': 'value: %s' % workflow_form['job_xml'].name}) }
                  % endif
                </div>
 
@@ -203,7 +201,7 @@ ${ layout.menubar(section='workflows') }
         <div class="form-actions center">
           <a href="${ url('oozie:list_workflows') }" class="btn">${ _('Back') }</a>
           % if user_can_edit_job:
-            <button class="btn btn-primary">${ _('Save') }</button>
+            <button data-bind="click: function() { save() }" class="btn btn-primary">${ _('Save') }</button>
           % endif
         </div>
         <div class="span3"></div>
@@ -240,6 +238,7 @@ ${ layout.menubar(section='workflows') }
   </form>
 </div>
 
+<div id="node-modal" class="modal hide" data-bind="template: $data.template"></div>
 
 <div id="confirmation" class="modal hide fade">
   <div class="modal-header">
@@ -255,67 +254,2237 @@ ${ layout.menubar(section='workflows') }
 
 <div id="modal-window" class="modal hide fade"></div>
 
-
 <style type="text/css">
-modal-window .modal-content {
+#modal-window .modal-content {
   height: 300px;
   overflow: auto;
 }
+
+#node-modal {
+  width: auto;
+}
+
+#node-modal .modal-content {
+  background: white;
+  max-height: 500px;
+  overflow-x: hidden;
+  overflow-y: scroll;
+  padding: 5px;
+}
+
+.action {border-style:solid; border-width:1px; border-color:LightGrey; padding: 3px;}
+
+.action-link:hover, .edit-node-link:hover {cursor: pointer;}
+
+#graph {
+  text-align: center;
+}
+
+.node-link-hover {
+  background: gray;
+}
+
+.node-fork-hover {
+  background: green;
+}
+
+.popover {
+  z-index: 2060 !important;
+}
 </style>
 
 <script src="/static/ext/js/knockout-2.1.0.js" type="text/javascript" charset="utf-8"></script>
+<script src="/static/ext/js/knockout.mapping-2.3.2.js" type="text/javascript" charset="utf-8"></script>
+<script src="/static/ext/js/jquery/plugins/jquery-ui-autocomplete-1.9.1.min.js" type="text/javascript" charset="utf-8"></script>
+<script src="/static/ext/js/jquery/plugins/jquery-ui-draggable-droppable-sortable-1.8.23.min.js" type="text/javascript" charset="utf-8"></script>
+
+
+% for form_info in action_forms:
+  ${ actions.action_form(action_form=form_info[1], node_type=form_info[0], template=True) }
+% endfor
+
+${ controls.fork_form('fork', True, javascript_attrs={'convert': 'function(data, event) { $data.convertToDecision(); $data._workflow.rebuild(); }'}) }
+
+${ controls.decision_form(link_form, default_link_form, 'decision', True) }
+
+<script type="text/html" id="emptyTemplate"></script>
+
+<script type="text/html" id="startTemplate">
+  <div class="row-fluid" data-bind="template: { name: 'linkTemplate', foreach: links }"></div>
+</script>
+
+<script type="text/html" id="nodeTemplate">
+  <div class="node node-action row-fluid">
+    <div class="action span12">
+      <div class="row-fluid">
+        <div class="span10 edit-node-link" title="Edit" data-bind="attr: { 'data-node-type': node_type() }">
+          <span class="label label-info" data-bind="text: (name()) ? name() : node_type() + '-' + id()"></span>
+        </div>
+      </div>
+
+      <div class="row-fluid">
+        <div class="span10 action-link" title="Edit">
+          <span data-bind="text: node_type"></span>
+          <br/>
+          <span class="node-description" data-bind="text: description"></span>
+        </div>
+      </div>
+
+      <div class="row-fluid">
+        <div class="span10">
+          <button data-bind="click: function(data, event) { $root.cloneNode.call($root, data, event); }"  class="btn" name="clone_action" title="Clone" type="button"><i class="icon-retweet"></i></button>
+          <button data-bind="click: function(data, event) { $root.removeNode.call($root, data, event); }"  class="btn" name="delete_action" title="Delete" type="button"><i class="icon-remove"></i></button>
+        </div>
+        <div class="span2">
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="row-fluid" data-bind="template: { name: 'linkTemplate', foreach: links }"></div>
+</script>
+
+<script type="text/html" id="forkTemplate">
+  <div class="node node-fork row-fluid">
+    <div class="action span12">
+      <div class="row-fluid">
+        <div class="span10 edit-node-link"  data-bind="attr: { 'data-node-type': node_type() }" title="Edit">
+          <span class="label label-info" data-bind="text: (name()) ? name() : node_type() + '-' + id()"></span>
+        </div>
+      </div>
+
+      <div class="row-fluid">
+        <div class="span10 action-link" title="Edit">
+          <span data-bind="text: node_type"></span>
+          <br/>
+          <span class="node-description" data-bind="text: description()"></span>
+        </div>
+      </div>
+    </div>
+
+    <div class="row-fluid node-fork-children">
+      <div class="row-fluid node-fork-child" data-bind="foreach: children">
+        <div data-bind="attr: {class: 'span' + (((12 / $parent.children().length) > 4) ? (12 / $parent.children().length) : 4)}">
+          <div class="row-fluid node-fork-child-link" data-bind="template: { name: 'linkTemplate', data: $parent.links()[$index()] }"></div>
+          <div data-bind="template: { name: function(item) { return item.view_template() }, foreach: $data }"></div>
+        </div>
+      </div>
+    </div>
+  </div>
+</script>
+
+<script type="text/html" id="decisionTemplate">
+  <div class="node node-decision row-fluid">
+    <div class="action span12">
+      <div class="row-fluid">
+        <div class="span10 edit-node-link"  data-bind="attr: { 'data-node-type': node_type() }" title="Edit">
+          <span class="label label-info" data-bind="text: (name()) ? name() : node_type() + '-' + id()"></span>
+        </div>
+      </div>
+
+      <div class="row-fluid">
+        <div class="span10 action-link" title="Edit">
+          <span data-bind="text: node_type"></span>
+          <br/>
+          <span class="node-description" data-bind="text: description()"></span>
+        </div>
+      </div>
+    </div>
+
+    <div class="row-fluid node-decision-children">
+      <div class="row-fluid node-decision-child" data-bind="foreach: children">
+        <div data-bind="attr: {class: 'span' + (((12 / $parent.children().length) > 4) ? (12 / $parent.children().length) : 4)}">
+          <div class="row-fluid node-decision-child-link" data-bind="template: { name: 'linkTemplate', data: $parent.links()[$index()] }"></div>
+          <div data-bind="template: { name: function(item) { return item.view_template() }, foreach: $data }"></div>
+        </div>
+      </div>
+    </div>
+
+    <div class="row-fluid node-decision-end">
+      <div class="node-decision-end">&nbsp;</div>
+    </div>
+  </div>
+</script>
+
+<script type="text/html" id="joinTemplate">
+  <div class="node node-join row-fluid">
+    <div class="action span12">
+      <div class="row-fluid">
+        <div class="span10">
+          <span class="label label-info" data-bind="text: (name()) ? name() : node_type() + '-' + id()"></span>
+        </div>
+      </div>
+
+      <div class="row-fluid">
+        <div class="span10">
+          <span data-bind="text: node_type()"></span>
+        </div>
+      </div>
+    </div>
+
+  </div>
+  <div class="row-fluid" data-bind="template: { name: 'linkTemplate', foreach: links() }"></div>
+</script>
+
+<script type="text/html" id="linkTemplate">
+  <div class="node-link">&nbsp;</div>
+</script>
 
 <script type="text/javascript">
-  confirmed = false;
-  $(document).ready(function(){
-    $(".action-link").click(function(){
-      window.location = $(this).attr('data-edit');
+/**
+ * Registry of models
+ *  - Each model should have an ID attribute.
+ */
+var RegistryModule = function($) {
+  var module = function() {
+    var self = this;
+
+    self.nodes = {};
+
+    module.prototype.initialize.apply(self, arguments);
+
+    return self;
+  };
+
+  $.extend(module.prototype, {
+    // Normal stuff
+    initialize: function() {},
+
+    toString: function() {
+      var self = this;
+
+      var s = $.map(self.nodes, function(node) {
+        return node.id();
+      }).join();
+      return s;
+    },
+
+    add: function(id, node) {
+      var self = this;
+      $(self).trigger('registry:add');
+      self.nodes[String(id)] = node;
+    },
+
+    remove: function(id) {
+      var self = this;
+      $(self).trigger('registry:remove');
+      delete self.nodes[String(id)];
+    },
+
+    get: function(id) {
+      var self = this;
+      return self.nodes[id];
+    },
+
+    clear: function() {
+      var self = this;
+
+      delete self.nodes;
+      self.nodes = {};
+    }
+  });
+
+  return module;
+};
+var Registry = RegistryModule($);
+
+/**
+ * Modal Control
+ * Displays a single node in a modal window of your choosing.
+ * Make sure to update the 'context' member and 'template' members.
+ * IMPORTANT: Use 'setTemplate', then 'show'.
+ */
+var ModalModule = function($, ko) {
+  var module = function(modal, template) {
+    var self = this;
+
+    self.modal = $(modal);
+    self.context = ko.observable();
+    self.template = ko.observable(template || '');
+    self.bound = false;
+  };
+
+  module.prototype.show = function(context) {
+    var self = this;
+
+    if (context) {
+      self.context(context);
+    }
+
+    ko.applyBindings(self, self.modal[0]);
+    self.modal.modal('show');
+  };
+
+  module.prototype.setTemplate = function(template) {
+    var self = this;
+
+    ko.cleanNode(self.modal[0]);
+    self.template( template );
+  };
+
+  module.prototype.recenter = function(offset_x, offset_y) {
+    var self = this;
+    var top = ( ($(window).height() - self.modal.outerHeight(false)) / 2 );
+    var left = ( ($(window).width() - self.modal.outerWidth(false)) / 2 );
+    if (top < 0) {
+      top = 0;
+    }
+    if (left < 0) {
+      left = 0;
+    }
+    top += offset_y || 0;
+    left += offset_x || 0;
+    self.modal.css({top: top +'px', left:  left+'px'});
+  };
+
+  return module;
+};
+var Modal = ModalModule($, ko);
+
+/**
+ * ID Generator
+ * Generate a new ID starting from 1.
+ * - Accepts a prefix that will be prepended like so: <prefix>:<id number>
+ */
+var IdGeneratorModule = function($) {
+  return function(options) {
+    var self = this;
+    $.extend(self, options);
+
+    self.counter = 1;
+
+    self.nextId = function() {
+      return ((self.prefix) ? self.prefix + ':' : '') + self.counter++;
+    };
+  };
+};
+var IdGenerator = IdGeneratorModule($);
+
+var ModelModule = function($) {
+  var module = function(attrs) {
+    var self = this;
+    $.extend(self, attrs);
+
+    module.prototype.initialize.apply(self, arguments);
+
+    return self;
+  };
+
+  $.extend(module.prototype, {
+    // Normal stuff
+    initialize: function(){},
+
+    toString: function() {
+      var self = this;
+      return JSON.stringify(self, null, '\t');
+    }
+  });
+
+  return module;
+};
+
+var WorkflowModel = ModelModule($);
+$.extend(WorkflowModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  start: 0,
+  end: 0,
+  schema_version: 0.4,
+  deployment_dir: '',
+  is_shared: true,
+  parameters: '[]',
+  job_xml: ''
+});
+
+var NodeModel = ModelModule($);
+$.extend(NodeModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: '',
+  workflow: 0,
+  child_links: []
+});
+
+var ForkModel = ModelModule($);
+$.extend(ForkModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'fork',
+  workflow: 0,
+  child_links: []
+});
+
+var DecisionModel = ModelModule($);
+$.extend(DecisionModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'decision',
+  workflow: 0,
+  child_links: []
+});
+
+var MapReduceModel = ModelModule($);
+$.extend(MapReduceModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'mapreduce',
+  workflow: 0,
+  files: [],
+  archives: [],
+  job_properties: [],
+  jar_path: '',
+  prepares: [],
+  job_xml: '',
+  child_links: []
+});
+
+var StreamingModel = ModelModule($);
+$.extend(StreamingModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'streaming',
+  workflow: 0,
+  files: [],
+  archives: [],
+  job_properties: [],
+  mapper: '',
+  reducer: '',
+  child_links: []
+});
+
+var JavaModel = ModelModule($);
+$.extend(JavaModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'java',
+  workflow: 0,
+  files: [],
+  archives: [],
+  job_properties: [],
+  jar_path: '',
+  prepares: [],
+  job_xml: '',
+  main_class: '',
+  args: '',
+  java_opts: '',
+  child_links: []
+});
+
+var PigModel = ModelModule($);
+$.extend(PigModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'pig',
+  workflow: 0,
+  files: [],
+  archives: [],
+  job_properties: [],
+  prepares: [],
+  job_xml: '',
+  params: [],
+  script_path: '',
+  child_links: []
+});
+
+var HiveModel = ModelModule($);
+$.extend(HiveModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'hive',
+  workflow: 0,
+  files: [],
+  archives: [],
+  job_properties: [],
+  prepares: [],
+  job_xml: '',
+  params: [],
+  script_path: '',
+  child_links: []
+});
+
+var SqoopModel = ModelModule($);
+$.extend(SqoopModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'sqoop',
+  workflow: 0,
+  files: [],
+  archives: [],
+  job_properties: [],
+  prepares: [],
+  job_xml: '',
+  params: [],
+  script_path: '',
+  child_links: []
+});
+
+var ShellModel = ModelModule($);
+$.extend(ShellModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'shell',
+  workflow: 0,
+  files: [],
+  archives: [],
+  job_properties: [],
+  prepares: [],
+  job_xml: '',
+  params: [],
+  command: '',
+  capture_output: false,
+  child_links: []
+});
+
+var SshModel = ModelModule($);
+$.extend(SshModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'ssh',
+  workflow: 0,
+  user: '',
+  host: '',
+  params: [],
+  command: '',
+  capture_output: false,
+  child_links: []
+});
+
+var DistCPModel = ModelModule($);
+$.extend(DistCPModel.prototype, {
+  id: 0,
+  name: '',
+  description: '',
+  node_type: 'distcp',
+  workflow: 0,
+  job_properties: [],
+  prepares: [],
+  job_xml: '',
+  params: [],
+  child_links: []
+});
+
+function nodeModelChooser(node_type) {
+  switch(node_type) {
+    case 'mapreduce':
+      return MapReduceModel;
+    case 'streaming':
+      return StreamingModel;
+    case 'java':
+      return JavaModel;
+    case 'pig':
+      return PigModel;
+    case 'hive':
+      return HiveModel;
+    case 'sqoop':
+      return SqoopModel;
+    case 'shell':
+      return ShellModel;
+    case 'ssh':
+      return SshModel;
+    case 'distcp':
+      return DistCPModel;
+    case 'fork':
+      return ForkModel;
+    case 'decision':
+      return DecisionModel;
+    default:
+      return NodeModel;
+  }
+}
+
+var IdGeneratorTable = {
+  mapreduce: new IdGenerator({prefix: 'mapreduce'}),
+  streaming: new IdGenerator({prefix: 'streaming'}),
+  java: new IdGenerator({prefix: 'java'}),
+  pig: new IdGenerator({prefix: 'pig'}),
+  hive: new IdGenerator({prefix: 'hive'}),
+  sqoop: new IdGenerator({prefix: 'sqoop'}),
+  shell: new IdGenerator({prefix: 'shell'}),
+  ssh: new IdGenerator({prefix: 'ssh'}),
+  distcp: new IdGenerator({prefix: 'distcp'}),
+  fork: new IdGenerator({prefix: 'fork'}),
+  decision: new IdGenerator({prefix: 'decision'}),
+  join: new IdGenerator({prefix: 'join'})
+};
+
+var NodeModule = function($, IdGeneratorTable) {
+  var META_LINKS = ['related', 'default', 'error'];
+
+  var linkTypeChooser = function(parent, child) {
+    if (child.node_type() == 'kill') {
+      return 'error';
+    }
+    switch(parent.node_type()) {
+      case 'start':
+        return (child.node_type() == 'end') ? 'related' : 'to';
+      case 'fork':
+        return (child.node_type() == 'join') ? 'related' : 'start';
+      case 'decision':
+        return 'start';
+      case 'join':
+        return 'to';
+      default:
+        return 'ok';
+    };
+  };
+
+  var module = function(workflow, model, registry) {
+    var self = this;
+
+    self.map(model);
+
+    self.links = ko.computed(function() {
+      var links = self.child_links().filter(function(element, index, arr) {
+        return $.inArray(element.name(), META_LINKS) == -1;
+      });
+      return links;
     });
 
-    $("*[name=delete_action]").click(function(){
-      if (!confirmed) {
-        var _this = $(this);
-        $("#confirmation .message").text('${ _("Are you sure you would like to delete this action?") }');
-        $("#confirmation").modal("show");
-        $("#confirmation a.primary").click(function() {
-          confirmed = true;
-          _this.trigger('click');
-        });
-      }
-      return confirmed;
+    self.meta_links = ko.computed(function() {
+      var links = self.child_links().filter(function(element, index, arr) {
+        return $.inArray(element.name(), META_LINKS) != -1;
+      });
+      return links;
     });
 
-    $(".modalWindow").click(function(){
-      var _this = $(this);
-      $.ajax({
-        url: _this.attr("data-modal-url"),
-        beforeSend: function(xhr){
-          xhr.setRequestHeader("X-Requested-With", "Hue");
+    self._workflow = workflow;
+
+    self.registry = registry;
+    self.children = ko.observableArray([]);
+    self.model = model;
+
+    self.edit_template = model.node_type + 'EditTemplate';
+    switch(model.node_type) {
+    case 'start':
+      self.view_template = ko.observable('startTemplate');
+    break;
+
+    case 'kill':
+    case 'end':
+      self.view_template = ko.observable('emptyTemplate');
+    break;
+
+    case 'fork':
+      self.view_template = ko.observable('forkTemplate');
+    break;
+
+    case 'decision':
+      self.view_template = ko.observable('decisionTemplate');
+    break;
+
+    case 'join':
+      self.view_template = ko.observable('joinTemplate');
+    break;
+
+    default:
+      self.view_template = ko.observable('nodeTemplate');
+    break;
+    }
+
+    // Data manipulation
+    if ('job_properties' in model) {
+
+      self.addProp = function() {
+        var prop = { name: ko.observable(""), value: ko.observable("") };
+        prop.name.subscribe(function(value) {
+          self.job_properties.valueHasMutated();
+        });
+        prop.value.subscribe(function(value) {
+          self.job_properties.valueHasMutated();
+        });
+        self.job_properties.push(prop);
+        // $(".propKey:last").each(addAutoComplete);
+      };
+
+      self.removeProp = function(val) {
+        self.job_properties.remove(val);
+      };
+    }
+
+    if ('files' in model) {
+
+      self.addFile = function() {
+        var prop = { name: ko.observable(""), dummy: ko.observable("") };
+        prop.name.subscribe(function(value) {
+          self.files.valueHasMutated();
+        });
+        prop.dummy.subscribe(function(value) {
+          self.files.valueHasMutated();
+        });
+        self.files.push(prop);
+      };
+
+      self.removeFile = function(val) {
+        self.files.remove(val);
+      };
+    }
+
+    if ('archives' in model) {
+
+      self.addArchive = function() {
+        var prop = { name: ko.observable(""), dummy: ko.observable("") };
+        prop.name.subscribe(function(value) {
+          self.archives.valueHasMutated();
+        });
+        prop.dummy.subscribe(function(value) {
+          self.archives.valueHasMutated();
+        });
+        self.archives.push(prop);
+      };
+
+      self.removeArchive = function(val) {
+        self.archives.remove(val);
+      };
+    }
+
+    if ('params' in model) {
+
+      self.addParam = function() {
+        var prop = { value: ko.observable(""), type: ko.observable("param") };
+        prop.value.subscribe(function(value) {
+          self.params.valueHasMutated();
+        });
+        prop.type.subscribe(function(value) {
+          self.params.valueHasMutated();
+        });
+        self.params.push(prop);
+      };
+
+      self.addArgument = function() {
+        var prop = { value: ko.observable(""), type: ko.observable("argument") };
+        prop.value.subscribe(function(value) {
+          self.params.valueHasMutated();
+        });
+        prop.type.subscribe(function(value) {
+          self.params.valueHasMutated();
+        });
+        self.params.push(prop);
+      };
+
+      self.addArg = function() {
+        var prop = { value: ko.observable(""), type: ko.observable("arg") };
+        prop.value.subscribe(function(value) {
+          self.params.valueHasMutated();
+        });
+        prop.type.subscribe(function(value) {
+          self.params.valueHasMutated();
+        });
+        self.params.push(prop);
+      };
+
+      self.addEnvVar = function() {
+        var prop = { value: ko.observable(""), type: ko.observable("env-var") };
+        prop.value.subscribe(function(value) {
+          self.params.valueHasMutated();
+        });
+        prop.type.subscribe(function(value) {
+          self.params.valueHasMutated();
+        });
+        self.params.push(prop);
+      };
+
+      self.removeParam = function(val) {
+        self.params.remove(val);
+      };
+    }
+
+    if ('prepares' in model) {
+
+      self.addPrepareDelete = function() {
+        var prop = { value: ko.observable(""), type: ko.observable("delete") };
+        prop.value.subscribe(function(value) {
+          self.prepares.valueHasMutated();
+        });
+        prop.type.subscribe(function(value) {
+          self.prepares.valueHasMutated();
+        });
+        self.prepares.push(prop);
+      };
+
+      self.addPrepareMkdir = function() {
+        var prop = { value: ko.observable(""), type: ko.observable("mkdir") };
+        prop.value.subscribe(function(value) {
+          self.prepares.valueHasMutated();
+        });
+        prop.type.subscribe(function(value) {
+          self.prepares.valueHasMutated();
+        });
+        self.prepares.push(prop);
+      };
+
+      self.removePrepare = function(val) {
+        self.prepares.remove(val);
+      };
+    }
+
+    self.initialize.apply(self, arguments);
+
+    return self;
+  };
+
+  $.extend(module.prototype, {
+    // Data.
+    children: null,
+    model: null,
+
+    // Normal stuff
+    /**
+     * Called when creating a new node
+     */
+    initialize: function(workflow, model, registry) {},
+
+    toString: function() {
+      return '';
+    },
+
+    /**
+     * Fetches registry
+     */
+    getRegistry: function() {
+      return registry;
+    },
+
+    /**
+     * Maps a model to self
+     * Called when creating a new node before any thing else
+     */
+    map: function(model) {
+      var self = this;
+
+      var map_params = function(options, subscribe) {
+        options.data = ($.type(options.data) == "string") ? $.parseJSON(options.data) : options.data;
+        if ($.isArray(options.data)) {
+          var mapping =  ko.mapping.fromJS(options.data);
+          $.each(mapping(), function(index, value) {
+            subscribe(value);
+          });
+          return mapping;
+        } else {
+          var mapping =  ko.mapping.fromJS(options.data, {});
+          subscribe(mapping);
+          return mapping;
+        }
+      };
+
+      // @see http://knockoutjs.com/documentation/plugins-mapping.html
+      var mapping = ko.mapping.fromJS(model, {
+        ignore: ['initialize','toString'],
+        job_properties: {
+          create: function(options) {
+            var mapping =  ko.mapping.fromJS($.parseJSON(options.data) || options.data, {});
+            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) {
+            var parent = options.parent;
+            var subscribe = function(mapping) {
+              mapping.name.subscribe(function(value) {
+                parent.files.valueHasMutated();
+              });
+              mapping.dummy.subscribe(function(value) {
+                parent.files.valueHasMutated();
+              });
+            };
+
+            return map_params(options, subscribe);
+          },
+        },
+        archives: {
+          create: 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);
+          },
+        },
+        params: {
+          create: 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);
+          },
+        },
+        prepares: {
+          create: 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);
+          },
         },
-        dataType: "html",
-        success: function(data){
-          var id = _this.attr("data-modal-id");
-          $(id).html(data);
-          $(id).modal("show");
+      });
+
+      $.extend(self, mapping);
+      $.each(mapping, function(key, value) {
+        var key = key;
+        if (ko.isObservable(self[key])) {
+          self[key].subscribe(function(value) {
+            model[key] = ko.mapping.toJS(value);
+          });
         }
       });
-    });
 
-    ko.applyBindings(window.viewModelparameters, $("#parameters")[0]);
-    ko.applyBindings(window.viewModeljob_properties, $("#job_properties")[0]);
+      $.each(self.child_links(), function(index, link) {
+        var $index = index;
+        link.comment.subscribe(function(value) {
+          self.model.child_links[$index].comment = value;
+        });
+
+        link.child.subscribe(function(value) {
+          self.model.child_links[$index].child = value;
+        });
+      });
 
-    $('#jobForm').submit(function() {
-      window.viewModelparameters.pre_submit();
-      window.viewModeljob_properties.pre_submit();
-    })
+    },
 
-    $("a[data-row-selector='true']").jHueRowSelector();
+    // Hierarchy manipulation.
+    /**
+     * Append node to self
+     * Does not support multiple children.
+     * Ensures single child.
+     * Ensures no cycles.
+     * 1. Finds all children and attaches them to node (cleans node first).
+     * 2. Remove all children from self.
+     * 3. Attach node to self.
+     */
+    append: function(node) {
+      var self = this;
 
-    $("*[rel=popover]").popover({
-      placement: 'right',
-      trigger: 'hover'
-    });
+      // Not fork nor decision nor self
+      if ($.inArray(self.node_type(), ['fork', 'decision']) == -1 && node.id() != self.id()) {
+        node.removeAllChildren();
+        $.each(self.links(), function(index, link) {
+          node.addChild(registry.get(link.child()));
+        });
+        self.removeAllChildren();
+        self.addChild(node);
+      }
+    },
+
+    /**
+     * Find all parents of current node
+     */
+    findParents: function() {
+      var self = this;
+
+      var parents = [];
+      $.each(registry.nodes, function(id, node) {
+        $.each(node.links(), function(index, link) {
+          if (link.child() == self.id()) {
+            parents.push(node);
+          }
+        });
+      });
+      return parents;
+    },
+
+    /**
+     * Find all children of current node
+     */
+    findChildren: function() {
+      var self = this;
+
+      var children = [];
+      $.each(self.links(), function(index, link) {
+        children.push(registry.get(link.child()));
+      });
+
+      return children;
+    },
+
+    /**
+     * Detach current node from the graph
+     * 1. Takes children of self node, removes them from self node, and adds them to each parent of self node.
+     * 2. The self node is then removed from every parent.
+     * 3. Does not support multiple children since we do not automatically fork.
+     */
+    detach: function() {
+      var self = this;
+
+      $.each(self.findParents(), function(index, parent) {
+        $.each(self.links(), function(index, link) {
+          var node = registry.get(link.child());
+          parent.replaceChild(self, node);
+        });
+      });
+
+      $(self).trigger('detached');
+
+      self.removeAllChildren();
+    },
+
+    /**
+     * Add child
+     * Update child links for this node.
+     */
+    addChild: function(node) {
+      var self = this;
+      var link = {
+        parent: ko.observable(self.id()),
+        child: ko.observable(node.id()),
+        name: ko.observable(linkTypeChooser(self, node)),
+        comment: ko.observable('')
+      };
+      self.child_links.unshift(link);
+    },
+
+    /**
+     * Remove child node
+     * 1. Find child node link
+     * 2. Remove child node link
+     */
+    removeChild: function(node) {
+      var self = this;
+      var spliceIndex = -1;
+
+      $.each(self.child_links(), function(index, link) {
+        if (link.child() == node.id()) {
+          spliceIndex = index;
+        }
+      });
+
+      if (spliceIndex > -1) {
+        self.child_links.splice(spliceIndex, 1);
+      }
+
+      return spliceIndex != -1;
+    },
+
+    /**
+     * Remove all children
+     * Removes all children except for related, default, and error links
+     * Note: we hold on to related, default, and error links because
+     *  we have to.
+     */
+    removeAllChildren: function() {
+      var self = this;
+      var keep_links = [];
+
+      $.each(self.child_links(), function(index, link) {
+        if ($.inArray(link.name(), META_LINKS) > -1) {
+          keep_links.push(link);
+        }
+      });
+
+      self.child_links.removeAll();
+      $.each(keep_links, function(index, link) {
+        self.child_links.push(link);
+      });
+    },
+
+    /**
+     * Replace child node with another node in the following way:
+     * 1. Find child index
+     * 2. Remove child index
+     * 3. Remove and remember every element after child
+     * 4. Add replacement node
+     * 5. Add every child that was remembered
+     */
+    replaceChild: function(child, replacement) {
+      var self = this;
+      var index = -1;
+
+      $.each(self.child_links(), function(i, link) {
+        if (link.child() == child.id()) {
+          index = i;
+        }
+      });
+
+      if (index > -1) {
+        self.child_links.splice(index, 1);
+        var links = self.child_links.splice(index);
+        var link = {
+          parent: ko.observable(self.id()),
+          child: ko.observable(replacement.id()),
+          name: ko.observable(linkTypeChooser(self, replacement)),
+          comment: ko.observable(''),
+        };
+        self.child_links.push(link);
+
+        $.each(links, function(index, link) {
+          self.child_links.push(link);
+        });
+      }
+
+      return index != -1;
+    },
+
+    isChild: function(node) {
+      var self = this;
+      var res = false;
+      $.each(self.links(), function(index, link) {
+        if (link.child() == node.id()) {
+          res = true;
+        }
+      });
+      return res;
+    },
+
+    erase: function() {
+      var self = this;
+      self.registry.remove(self.id());
+    },
+  });
+
+  return module;
+};
+
+var Node = NodeModule($, IdGeneratorTable);
+
+var ForkNode = NodeModule($, IdGeneratorTable);
+$.extend(ForkNode.prototype, Node.prototype, {
+  // Join nodes are connected through 'related' links
+  join: function() {
+    var self = this;
+
+    var join = null;
+    $.each(self.child_links(), function(index, link) {
+      if (link.name() == 'related') {
+        join = self.registry.get(link.child());
+      }
+    });
+    return join;
+  },
+
+  /**
+   * Append a node to the current fork
+   * Also adds join node to node.
+   * When adding the join node, append will remove all the children from the join!
+   * We need to make sure the join remembers its children.
+   * NOTE: Cannot append a fork! Use addChild or replaceChild instead!
+   */
+  append: function(node) {
+    var self = this;
+
+    if (node.node_type() != 'fork') {
+      var join = self.join();
+      if (join.id() != node.id()) {
+        var children = join.findChildren();
+
+        self.addChild(node);
+        node.append(join);
+
+        // remember children
+        $.each(children, function(index, child) {
+          join.addChild(child);
+        });
+      }
+    }
+  },
+
+  /**
+   * Replace child node with another node
+   * 1. Remove child if the replacement is related join
+   * 2. Apply Node.replaceChild for all other causes
+   * NOTE: can assume the only join this fork will ever see is the related join!
+   * This is because the related will always be the closest join for children.
+   * Also, the operations allowed that would make this all possible: detach, append;
+   * are inherently going to remove any other joins if they exist.
+   * This is also easier given nodes are contained within Forks!
+   */
+  replaceChild: function(child, replacement) {
+    var self = this;
+
+    var ret = true;
+    if (self.join().id() == replacement.id()) {
+      ret = self.removeChild(child);
+    } else {
+      ret = Node.prototype.replaceChild.apply(self, arguments);
+    }
+
+    var links = self.links().filter(function(element, index, arr) {
+      return self.registry.get(element.child()).node_type() != 'join';
+    });
+
+    if (links.length < 2) {
+      self.detach();
+      self.join().detach();
+
+      self.erase();
+      self.join().erase();
+    }
+
+    return ret;
+  },
+
+  /**
+   * Converts fork node into decision node in the following way:
+   * 1. Copies contents of current fork node into a new decision node
+   * 2. Detach fork node
+   * 3. Erase fork node
+   * 4. Append decision node to parent
+   */
+  convertToDecision: function() {
+    var self = this;
+    var join = self.join();
+    var end = null;
+    var child = join.findChildren()[0];
+
+    if (child.findParents().length == 1) {
+      end = child;
+    }
+
+    // Attaches child of join to parents of join
+    join.detach();
+
+    var decision_model = new DecisionModel({
+      id: IdGeneratorTable['decision'].nextId(),
+      name: self.name(),
+      description: self.description(),
+      node_type: 'decision',
+      workflow: self.workflow(),
+      child_links: self.model.child_links,
+    });
+
+    var default_link = {
+      parent: decision_model.id,
+      child: self._workflow.end(),
+      name: 'default',
+      comment: '',
+    };
+
+    decision_model.child_links.push(default_link);
+
+    $.each(decision_model.child_links, function(index, link) {
+      link.parent = decision_model.id;
+    });
+
+    var decision_node = new DecisionNode(self._workflow, decision_model, self.registry);
+    if (end) {
+      decision_node.addEnd(end);
+    }
+    var parents = self.findParents();
+    decision_node.removeChild(join);
+
+    join.erase();
+    self.erase();
+
+    self.registry.add(decision_node.id(), decision_node);
+
+    $.each(parents, function(index, parent) {
+      parent.replaceChild(self, decision_node);
+    });
+
+  },
+});
+
+var DecisionNode = NodeModule($, IdGeneratorTable);
+$.extend(DecisionNode.prototype, ForkNode.prototype, {
+  initialize: function(workflow, model, registry) {
+    var self = this;
+    var registry = registry;
+
+    var end = null;
+
+    $.each(self.child_links(), function(index, link) {
+      if (link.name() == 'related') {
+        $(registry).bind('registry:add', function(e) {
+          var end = self.end();
+          if (end) {
+            self.removeEnd();
+            self.addEnd(end);
+            $(registry).unbind(e);
+          }
+        });
+      }
+    });
+  },
+
+  // Only a single end allowed for now!
+  // Finds end for branch
+  findEnd: function( ) {
+    var self = this;
+
+    var end = self.end();
+
+    if (!end) {
+      return self._findEnd( self, 0 );
+    }
+
+    return end;
+  },
+
+  _findEnd: function( node, count ) {
+    var self = this;
+
+    var end = null;
+
+    if (node.findParents().length > 1 && --count == 0) {
+      return node;
+    }
+
+    if (node.node_type() == 'decision' || node.node_type() == 'fork') {
+      count++;
+    }
+
+    $.each(node.findChildren(), function(index, node) {
+      if (end == null) {
+        end = self._findEnd(node, count);
+      }
+    });
+
+    return end;
+  },
+
+  end: function() {
+    var self = this;
+
+    var end = null;
+
+    $.each(self.child_links(), function(index, link) {
+      if (link.name() == 'related') {
+        end = self.registry.get(link.child());
+      }
+    });
+
+    return end;
+  },
+
+  addEnd: function(node) {
+    var self = this;
+
+    self.child_links.push({
+      parent: ko.observable(self.id()),
+      child: ko.observable(node.id()),
+      name: ko.observable('related'),
+      comment: ko.observable(''),
+    });
+
+    $(node).one('detached', function(e) {
+      var end = self.end();
+
+      if (end.links().length == 1) {
+        self.removeEnd();
+        var new_end = self.findEnd();
+        self.addEnd(new_end);
+      } else {
+        // Should never hit this case.
+        self.removeEnd();
+      }
+    });
+  },
+
+  removeEnd: function() {
+    var self = this;
+
+    var spliceIndex = -1;
+
+    $.each(self.child_links(), function(index, link) {
+      if (link.name() == 'related') {
+        spliceIndex = index;
+      }
+    });
+
+    if (spliceIndex > -1) {
+      var end = registry.get(self.child_links()[spliceIndex].child());
+
+      self.child_links.splice(spliceIndex, 1);
+      return true;
+    }
+
+    return false;
+  },
+
+  isChild: function(node) {
+    var self = this;
+
+    return self._isChild(node, self, 0);
+  },
+
+  _isChild: function( test_child, node, count ) {
+    var self = this;
+
+    var result = node.id() == test_child.id();
+
+    if (node.findParents().length > 1 && --count == 0) {
+      return result;
+    }
+
+    if (node.node_type() == 'decision' || node.node_type() == 'fork') {
+      count++;
+    }
+
+    $.each(node.findChildren(), function(index, node) {
+      if (!result) {
+        result = self._isChild(test_child, node, count);
+      }
+    });
+
+    return result;
+  },
+
+  /**
+   * Append a node to the current decision
+   * Also appends end node to node.
+   * NOTE: Cannot append a decision! Use addChild or replaceChild instead!
+   */
+  append: function(node) {
+    var self = this;
+
+    var end = self.findEnd();
+
+    if (end.id() == node.id()) {
+      return false;
+    }
+
+    self.addChild(node);
+    node.append(end);
+
+    return true;
+  },
+
+  /**
+   * Replace child node with another node
+   * 1. Remove child if the replacement is end
+   * 2. Apply Node.replaceChild for all other causes
+   */
+  replaceChild: function(child, replacement) {
+    var self = this;
+
+    var ret = true;
+    var end = self.findEnd();
+
+    if (end && end.id() == replacement.id()) {
+      ret = self.removeChild(child);
+    } else {
+      ret = Node.prototype.replaceChild.apply(self, arguments);
+    }
+
+    if (self.links().length < 2) {
+      self.detach();
+      self.erase();
+    }
+
+    return ret;
+  },
+});
+
+/**
+ * Workflow module
+ */
+var WorkflowModule = function($, NodeModelChooser, Node, ForkNode, DecisionNode, IdGeneratorTable) {
+  var module = function(options) {
+    var self = this;
+
+    // @see http://knockoutjs.com/documentation/plugins-mapping.html
+    var mapping = ko.mapping.fromJS(options.model, {
+      ignore: ['initialize','toString'],
+      job_properties: {
+        create: 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: {
+        // Will receive individual objects to subscribe.
+        // Containing array is mapped automagically
+        create: 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;
+        },
+      },
+    });
+
+    $.extend(self, mapping);
+    $.each(mapping['__ko_mapping__'].mappedProperties, function(key, value) {
+      var key = key;
+      self[key].subscribe(function(value) {
+        self.model[key] = ko.mapping.toJS(value);
+      });
+    });
+
+    self.model = options.model;
+    self.registry = options.registry;
+    self.options = options;
+    self.el = $('#workflow');
+    self.nodes = ko.observableArray([]);
+    self.kill = null;
+
+    self.url = ko.computed(function() {
+      return '/oozie/workflows/' + self.id()
+    });
+
+    // Events
+    self.el.on('workflow:rebuild', function() {
+      self.rebuild();
+    });
+    self.el.on('workflow:events:load', function() {
+      self.dragAndDropEvents();
+    });
+    self.el.on('workflow:droppables:load', function() {
+      self.droppables();
+    });
+    self.el.on('workflow:draggables:load', function() {
+      self.draggables();
+    });
+
+    self.dragAndDropEvents();
+    self.el.trigger('workflow:events:loaded');
+
+    module.prototype.initialize.apply(self, arguments);
+
+    return self;
+  };
+
+  $.extend(module.prototype, {
+    // Normal stuff
+    initialize: function(options) {
+      var self = this;
+
+      if ('data' in options) {
+
+        // Initialize nodes
+        if (options.data.nodes) {
+          self.registry.clear();
+
+          $.each(options.data.nodes, function(index, node) {
+            var NodeModel = NodeModelChooser(node.node_type);
+            var model = new NodeModel(node);
+            var temp = null;
+            switch(node.node_type) {
+              case 'fork':
+                temp = new ForkNode(self, model, self.registry);
+              break;
+              case 'decision':
+                temp = new DecisionNode(self, model, self.registry);
+              break;
+              case 'kill':
+                temp = self.kill = new Node(self, model, self.registry);
+              break;
+              default:
+                temp = new Node(self, model, self.registry);
+              break;
+            }
+
+            self.registry.add(temp.id(), temp);
+          });
+        }
+
+        // Update data
+        $.each(options.data, function (key, value) {
+          if (key in self) {
+            switch(key) {
+              case 'job_properties':
+              case 'parameters':
+                // These may be serialized JSON data since that is how they are stored
+                self[key].removeAll();
+                var arr = $.parseJSON(value) || value;
+
+                $.each(arr, function(index, obj) {
+                  var mapping = ko.mapping.fromJS(obj);
+                  mapping.name.subscribe(function(value) {
+                    self[key].valueHasMutated();
+                  });
+                  mapping.value.subscribe(function(value) {
+                    self[key].valueHasMutated();
+                  });
+                  self[key].push(mapping);
+                });
+              break;
+
+              case 'nodes':
+              break;
+
+              default:
+                self[key](value);
+              break;
+            }
+          }
+        });
+      }
+    },
+
+    toString: function() {
+      return '';
+      var s = '[';
+      $.each(self.registry.nodes, function(key, node) {
+        s += node.model.toString() + ",\n";
+      });
+      return s + ']';
+    },
+
+    // Data manipulation
+    cloneNode: function(node, event) {
+      var self = this;
+
+      var model_copy = $.extend(true, {}, node.model);
+      var template = model_copy.node_type + 'EditTemplate';
+      var NodeModel = NodeModelChooser(node.node_type());
+
+      model_copy.id = IdGeneratorTable[model_copy.node_type].nextId();
+      model_copy.name += '-copy';
+      model_copy.child_links = [];
+
+      var model = new NodeModel(model_copy);
+      var new_node = new Node(self, model, self.registry);
+
+      self.registry.add(new_node.id(), new_node);
+
+      new_node.addChild(self.kill);
+      node.append(new_node);
+
+      self.rebuild();
+
+      modal.setTemplate(template);
+      modal.show(new_node);
+      modal.recenter(280, 250);
+
+      $(".pathChooser").each(function(){
+        var self = $(this);
+        self.after(getFileBrowseButton(self));
+      });
+
+      // $(".propKey").each(addAutoComplete);
+
+      $("*[rel=popover]").popover({
+        placement: 'right',
+        trigger: 'hover'
+      }).css('z-index', 9999);
+    },
+
+    removeNode: function(node, event) {
+      var self = this;
+
+      node.detach();
+      node.erase();
+
+      self.rebuild();
+    },
+
+    save: function( options ) {
+      var self = this;
+
+      var options = options || {};
+
+      data = $.extend(true, {}, self.model);
+
+      var nodes = [];
+      $.each(self.registry.nodes, function(key, node) {
+        nodes.push(node.model);
+      });
+      data['nodes'] = nodes;
+
+      var request = $.extend({
+        url: self.url() + '/save',
+        type: 'POST',
+        data: { workflow: JSON.stringify(data) },
+        success: function() {
+          $.jHueNotify.info("${ _('Workflow saved') }");
+        },
+        error: function() {
+          $.jHueNotify.error("${ _('Could not save workflow') }");
+        },
+      }, options);
+
+      var success_handler = request['success'];
+      request['success'] = function(data, event) {
+        if ($.isFunction(success_handler)) {
+          success_handler(data, event);
+        }
+
+        self.reload(data.data);
+      };
+
+      $.ajax(request);
+    },
+
+    load: function() {
+      var self = this;
+
+      $.getJSON(self.url(), function(data) {
+        if (data.status == 0) {
+          self.reload(data.data);
+        } else {
+          $.jHueNotify.error("${ _('Received invalid response from server: ') }" + JSON.stringify(data));
+        }
+      });
+    },
+
+    reload: function(data) {
+      var self = this;
+
+      // Clear all children
+      $.each(self.registry.nodes, function(index, node) {
+        node.children.removeAll();
+      });
+      self.nodes.removeAll();
+
+      self.initialize({data: data});
+      self.rebuild();
+      self.el.trigger('workflow:loaded');
+    },
+
+    addParameter: function(data, event) {
+      var self = this;
+      var prop = { name: ko.observable(""), value: ko.observable("") };
+      // force bubble up to containing observable array.
+      prop.name.subscribe(function(){
+        self.parameters.valueHasMutated();
+      });
+      prop.value.subscribe(function(){
+        self.parameters.valueHasMutated();
+      });
+      self.parameters.push(prop);
+    },
+
+    removeParameter: function(data, event) {
+      var self = this;
+      self.parameters.remove(data);
+    },
+
+    addJobProperty: function(data, event) {
+      var self = this;
+      var prop = { name: ko.observable(""), value: ko.observable("") };
+      // force bubble up to containing observable array.
+      prop.name.subscribe(function(){
+        self.parameters.valueHasMutated();
+      });
+      prop.value.subscribe(function(){
+        self.parameters.valueHasMutated();
+      });
+      self.job_properties.push(prop);
+    },
+
+    removeJobProperty: function(data, event) {
+      var self = this;
+      self.job_properties.remove(data);
+    },
+
+    // Workflow UI
+    // Function to build nodes... recursively.
+    build: function() {
+      var self = this;
+
+      var maximum = 50;
+      var count = 0;
+
+      var methodChooser = function(node, collection, skip_parents_check) {
+        if (count++ >= maximum) {
+          console.error('Hit maximum number of node recursion: ' + maximum);
+          return null;
+        }
+
+        if (!node) {
+          return node;
+        }
+
+        var parents = node.findParents();
+
+        // Found end of decision node or found join!
+        if (parents.length > 1 && !skip_parents_check) {
+          return node;
+        }
+
+        switch(node.node_type()) {
+        case 'start':
+        case 'end':
+        case 'kill':
+        case 'fork':
+        case 'decision':
+        case 'join':
+          return control(node, collection);
+        default:
+          return normal(node, collection);
+        }
+      };
+
+      var normal = function(node, collection) {
+        collection.push(node);
+
+        var retNode = null;
+        $.each(node.links(), function(index, link) {
+          var next_node = self.registry.get(link.child());
+          retNode = methodChooser(next_node, collection, false, true);
+        });
+        return retNode;
+      };
+
+      var control = function(node, collection, skip_parents_check) {
+        switch(node.node_type()) {
+          case 'start':
+          case 'end':
+          case 'kill':
+          case 'join':
+            return normal(node, collection, false, true);
+
+          case 'fork':
+            collection.push(node);
+
+            // Wait for join.
+            // Iterate through all children and add them to child collection.
+            var join = null;
+            $.each(node.links(), function(index, link) {
+              var next_node = self.registry.get(link.child());
+              var collection = ko.observableArray([]);
+              node.children.push(collection);
+              join = methodChooser(next_node, collection, false, true);
+            });
+
+            // Add join to collection, then find its single child.
+            return methodChooser(join, collection, true, true);
+
+          case 'decision':
+            collection.push(node);
+
+            // Waits for end, then runs through children of end node
+            var end = null;
+            $.each(node.links(), function(index, link) {
+              var next_node = self.registry.get(link.child());
+              var collection = ko.observableArray([]);
+              node.children.push(collection);
+              end = methodChooser(next_node, collection, true, true);
+            });
+
+            // Add end
+            if (node.end() && end.id() == node.end().id()) {
+              return methodChooser(end, collection, true, true);
+            } else {
+              return end;
+            }
+
+          default:
+            // Should never get here.
+            return node;
+        }
+      };
+
+      methodChooser(self.registry.get(self.start()), self.nodes, false, true);
+    },
+
+    rebuild: function() {
+      var self = this;
+
+      // Clear all children
+      $.each(self.registry.nodes, function(index, node) {
+        node.children.removeAll();
+      });
+      self.nodes.removeAll();
+
+      // Rebuild
+      self.build();
+      self.draggables();
+      self.droppables();
+
+      self.el.trigger('workflow:rebuilt');
+    },
+
+    draggables: function() {
+      var self = this;
+
+      self.el.find('.node-action').each(function(index, el) {
+        if (!$(el).hasClass('ui-draggable')) {
+          $(el).draggable({
+            containment: [ self.el.offset().left - 10, self.el.offset().top - 10,
+                           self.el.offset().left + self.el.outerWidth(), self.el.offset().top + self.el.outerHeight() ],
+            handle: '.node',
+            refreshPositions: true,
+            revert: true
+          });
+        }
+      });
+    },
+
+    droppables: function() {
+      var self = this;
+
+      self.el.find('.node-link').each(function(index, el) {
+        $(el).droppable({
+          'hoverClass': 'node-link-hover',
+          'greedy': true,
+          'accept': '.node-action',
+          'tolerance': 'pointer'
+        });
+      });
+
+      self.el.find('.node-decision-end').each(function(index, el) {
+        $(el).droppable({
+          'hoverClass': 'node-link-hover',
+          'greedy': true,
+          'accept': '.node-action',
+          'tolerance': 'pointer'
+        });
+      });
+
+      self.el.find('.node-fork .action').each(function(index, el) {
+        $(el).droppable({
+          'hoverClass': 'node-fork-hover',
+          'greedy': true,
+          'accept': '.node-action',
+          'tolerance': 'pointer'
+        });
+      });
+
+      self.el.find('.node-decision .action').each(function(index, el) {
+        $(el).droppable({
+          'hoverClass': 'node-fork-hover',
+          'greedy': true,
+          'accept': '.node-action',
+          'tolerance': 'pointer'
+        });
+      });
+
+      self.el.find('.node-action .action').each(function(index, el) {
+        $(el).droppable({
+          'hoverClass': 'node-action-hover',
+          'greedy': true,
+          'accept': '.node-action',
+          'tolerance': 'pointer'
+        });
+      });
+    },
+
+    dragAndDropEvents: function() {
+      var self = this;
+
+      // Build event delegations.
+      // Drop on node link
+      self.el.on('drop', '.node-link', function(e, ui) {
+        // draggable should be a node.
+        // droppable should be a link.
+        var draggable = ko.contextFor(ui.draggable[0]).$data;
+        var droppable = ko.contextFor(this).$data;
+
+        // If newParent is fork, prepend to child instead.
+        // This will make it so that we can drop and drop to the top of a node list within a fork.
+        var newParent = self.registry.get(droppable.parent());
+
+        if (newParent.id() != draggable.id()) {
+          if (newParent.isChild(draggable)) {
+            if (draggable.findParents().length > 1) {
+              // End of decision tree is being dragged to the bottom of a branch
+              draggable.detach();
+              newParent.append(draggable);
+
+              self.rebuild();
+            }
+          } else {
+            switch(newParent.node_type()) {
+            case 'fork':
+            case 'decision':
+              draggable.detach();
+
+              var child = self.registry.get(droppable.child());
+              newParent.replaceChild(child, draggable);
+              draggable.addChild(child);
+            break;
+
+            case 'join':
+              // Join may disappear when we detach...
+              // Remember its children and append to child.
+              var parents = newParent.findParents();
+              draggable.detach();
+
+              if (newParent.findParents().length < 2) {
+                $.each(parents, function(index, parent) {
+                  parent.append(draggable);
+                });
+              } else {
+                newParent.append(draggable);
+              }
+            break;
+
+            default:
+              draggable.detach();
+              newParent.append(draggable);
+            break;
+            }
+
+            self.rebuild();
+          }
+        }
+
+        // Prevent bubbling events
+        return false;
+      });
+
+      // Drop on fork
+      self.el.on('drop', '.node-fork', function(e, ui) {
+        // draggable should be a node.
+        // droppable should be a fork.
+        var draggable = ko.contextFor(ui.draggable[0]).$data;
+        var droppable = ko.contextFor(this).$data;
+
+        if (!droppable.isChild(draggable) && droppable.id() != draggable.id()) {
+          draggable.detach();
+          droppable.append(draggable);
+
+          self.rebuild();
+        }
+
+        // Prevent bubbling events
+        return false;
+      });
+
+      // Drop on decision
+      self.el.on('drop', '.node-decision', function(e, ui) {
+        // draggable should be a node.
+        // droppable should be a fork.
+        var draggable = ko.contextFor(ui.draggable[0]).$data;
+        var droppable = ko.contextFor(this).$data;
+
+        if (!droppable.isChild(draggable) && droppable.id() != draggable.id()) {
+          draggable.detach();
+          droppable.append(draggable);
+
+          self.rebuild();
+        }
+
+        // Prevent bubbling events
+        return false;
+      });
+
+      // Drop on decision end link
+      self.el.on('drop', '.node-decision-end', function(e, ui) {
+        // draggable should be a node.
+        // droppable should be a decision end link... which should give us the decision node.
+        var draggable = ko.contextFor(ui.draggable[0]).$data;
+        var droppable = ko.contextFor(this).$data;
+        var end = droppable.findEnd(droppable);
+
+        if (end.id() != draggable.id()) {
+          draggable.detach();
+
+          $.each(end.findParents(), function(index, parent) {
+            if (droppable.isChild(parent)) {
+              parent.append(draggable);
+            }
+          });
+
+          droppable.removeEnd();
+          droppable.addEnd(draggable);
+
+          self.rebuild();
+        }
+
+        // Prevent bubbling events
+        return false;
+      });
+
+      // Drop on action
+      self.el.on('drop', '.node-action', function(e, ui) {
+        // draggable should be a node.
+        // droppable should be a node.
+        var draggable = ko.contextFor(ui.draggable[0]).$data;
+        var droppable = ko.contextFor(this).$data;
+
+        // Create a fork and join programatically.
+        var newParents = droppable.findParents();
+
+        // skip forking beneathe a decision node
+        if (droppable.id() != draggable.id() && newParents.length == 1 && draggable.findParents().length == 1) {
+          var ForkModel = NodeModelChooser('fork');
+          var JoinModel = NodeModelChooser('join');
+
+          var fork = new ForkModel({
+            id: IdGeneratorTable['fork'].nextId(),
+            description: "",
+            workflow: self.id,
+            node_type: "fork",
+            child_links: []
+          });
+          var forkNode = new ForkNode(self, fork, self.registry);
+
+          var join = new JoinModel({
+            id: IdGeneratorTable['join'].nextId(),
+            description: "",
+            workflow: self.id,
+            node_type: "join",
+            child_links: []
+          });
+          var joinNode = new Node(self, join, self.registry);
+
+          self.registry.add(forkNode.id(), forkNode);
+          self.registry.add(joinNode.id(), joinNode);
+
+          forkNode.addChild(joinNode);
+
+          // Handles fork creation.
+          $.each(newParents, function(index, parent) {
+            parent.replaceChild(droppable, forkNode);
+          });
+          draggable.detach();
+          forkNode.append(draggable);
+          forkNode.append(droppable);
+
+          self.rebuild();
+        }
+
+        // Prevent bubbling events.
+        return false;
+      });
+    }
+  });
+
+  return module;
+};
+var Workflow = WorkflowModule($, nodeModelChooser, Node, ForkNode, DecisionNode, IdGeneratorTable);
+
+/**
+ * Known issues with this way of doing things...
+ *  - 2 Layers of models.
+ */
+// Fetch all nodes from server.
+var workflow_model = {
+  id: ${ workflow.id },
+  name: "${ workflow.name }",
+  description: "${ workflow.description }",
+  start: ${ workflow.start.id },
+  end: ${ workflow.end.id },
+  job_xml: "${ workflow.job_xml }",
+  deployment_dir: "${ workflow.deployment_dir }",
+  is_shared: "${ workflow.is_shared }" == "True",
+  parameters: ${ workflow.parameters },
+  job_properties: ${ workflow.job_properties },
+};
+var registry = new Registry();
+var workflow = new Workflow({
+  model: workflow_model,
+  registry: registry
+});
+var modal = new Modal("#node-modal");
+workflow.load();
+
+$('#node-modal').on('shown', function() {
+  $(".pathChooser").each(function(){
+    var self = $(this);
+    self.after(getFileBrowseButton(self));
+  });
+
+  // $(".propKey").each(addAutoComplete);
+
+  $("*[rel=popover]").popover({
+    placement: 'right',
+    trigger: 'hover'
+  }).css('z-index', 9999);
+});
+
+$('#workflow').on('click', '.edit-node-link', function(e) {
+  var context = ko.contextFor(this).$data;
+  modal.setTemplate(context.edit_template);
+  modal.show(context);
+  modal.recenter(280, 250);
+
+  $("input[name='job_xml']").addClass("pathChooser").after(getFileBrowseButton($("input[name='job_xml']")));
+  $("input[name='jar_path']").addClass("pathChooser").after(getFileBrowseButton($("input[name='jar_path']")));
+});
+
+$('#workflow').on('click', '.new-node-link', function(e) {
+  var node_type = $(this).attr('data-node-type');
+  var template = node_type + 'EditTemplate';
+  var NodeModel = nodeModelChooser(node_type);
+  var model = new NodeModel({
+    id: IdGeneratorTable[node_type].nextId(),
+    node_type: node_type,
+  });
+  var node = new Node(self, 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);
+});
+
+ko.bindingHandlers.fileChooser = {
+    init: function(element, valueAccessor, allBindings, model) {
+    var self = $(element);
+    self.after(getFileBrowseButton(self));
+  }
+};
+
+ko.applyBindings(workflow, $('#workflow')[0]);
+
+function getFileBrowseButton(inputElement) {
+  return $("<button>").addClass("btn").addClass("fileChooserBtn").text("..").click(function(e){
+    e.preventDefault();
+    $("#fileChooserModal").jHueFileChooser({
+      onFileChoose: function(filePath) {
+          inputElement.val(filePath);
+          inputElement.change();
+          $("#chooseFile").modal("hide");
+      },
+      createFolder: false,
+      initialPath: "${ workflow.deployment_dir }"
+    });
+    $("#chooseFile").modal("show");
+  })
+}
+
+// Handles adding autocomplete to job properties.
+// We need to propagate the selected value to knockoutjs.
+var addAutoComplete = function(i, elem) {
+  var propertiesHint = '';
+  $(elem).autocomplete({
+    source: propertiesHint,
+    select: function(event, ui) {
+      var context = ko.contextFor(this);
+      context.$data.name = ui.item.value;
+    }
   });
+};
 </script>
 
 ${ utils.path_chooser_libs(True) }

+ 0 - 504
apps/oozie/src/oozie/templates/editor/edit_workflow_action.mako

@@ -1,504 +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.
-
-<%!
-  from desktop.views import commonheader, commonfooter
-  from django.utils.translation import ugettext as _
-%>
-
-<%namespace name="layout" file="../navigation-bar.mako" />
-<%namespace name="utils" file="../utils.inc.mako" />
-
-${ commonheader(_("Oozie App"), "oozie", user, "100px") }
-${ layout.menubar(section='workflows') }
-
-
-<div class="container-fluid">
-  <h1>${ _('Workflow') } <a href="${ url('oozie:edit_workflow', workflow=workflow.id) }">${ workflow.name }</a> : ${ _('Action') }
-  % if action_form.instance.id:
-    ${ action_form.instance }
-  % endif
-  </h1>
-
-  <br/>
-
-  <div class="row">
-    <div class="span12">
-    <form class="form-horizontal" id="actionForm" action="${ form_url }" method="POST">
-      <fieldset>
-      % for field in action_form:
-        % if field.html_name in ('name', 'description'):
-          ${ utils.render_field(field) }
-        % endif
-      % endfor
-
-      ${ utils.render_constant(_('Action type'), node_type) }
-
-      <hr/>
-
-      <div class="control-group">
-        <label class="control-label"></label>
-        <div class="controls">
-        <p class="alert alert-info span5">
-          ${ _('All the paths are relative to the deployment directory. They can be absolute but this is not recommended.') }
-          <br/>
-          ${ _('You can parameterize values using case sensitive') } <code>${"${"}PARAMETER}</code>.
-        </p>
-        % if node_type == 'ssh':
-          <p class="alert alert-warn span5">
-            ${ _('The ssh server requires passwordless login') }.
-          </p>
-        % endif
-        </div>
-      </div>
-
-      % for field in action_form:
-        % if field.html_name not in ('name', 'description', 'node_type', 'job_xml'):
-          ${ utils.render_field(field) }
-        % endif
-      % endfor
-
-      % if 'prepares' in action_form.fields:
-        <div class="control-group" rel="popover"
-            data-original-title="${ action_form['prepares'].label }" data-content="${ action_form['prepares'].help_text }">
-          <label class="control-label">${ _('Prepare') }</label>
-          <div class="controls">
-            <table class="table-condensed designTable" data-bind="visible: prepares().length > 0">
-              <thead>
-                <tr>
-                  <th>${ _('Type') }</th>
-                  <th>${ _('Value') }</th>
-                  <th/>
-                </tr>
-              </thead>
-              <tbody data-bind="foreach: prepares">
-                <tr>
-                  <td>
-                    <span class="span4 required" data-bind="text: type" />
-                  </td>
-                  <td>
-                    <input type="text" class="input span4 required pathChooser" data-bind="fileChooser: $data, value: value, uniqueName: false" />
-                  </td>
-                  <td><a class="btn" href="#" data-bind="click: $root.removePrepare">${ _('Delete') }</a></td>
-                </tr>
-              </tbody>
-            </table>
-
-            % if action_form['prepares'].errors:
-              <div class="alert alert-error">
-                ${ unicode(action_form['prepares'].errors) | n }
-              </div>
-            % endif
-
-            <button class="btn" data-bind="click: addPrepareDelete">${ _('Add delete') }</button>
-            <button class="btn" data-bind="click: addPrepareMkdir">${ _('Add mkdir') }</button>
-          </div>
-        </div>
-      % endif
-
-      % if 'params' in action_form.fields:
-        <div class="control-group" rel="popover"
-            data-original-title="${ action_form['params'].label }" data-content="${ action_form['params'].help_text }">
-          <label class="control-label">${ _('Params') }</label>
-          <div class="controls">
-            <table class="table-condensed designTable" data-bind="visible: params().length > 0">
-              <thead>
-                <tr>
-                  <th>${ _('Type') }</th>
-                  <th>${ _('Value') }</th>
-                  <th/>
-                </tr>
-              </thead>
-              <tbody data-bind="foreach: params">
-                <tr>
-                  <td>
-                    <span class="span4 required" data-bind="text: type" />
-                  </td>
-                  <td>
-                    <input type="text" class="input span4 required pathChooser" data-bind="fileChooser: $data, value: value, uniqueName: false" />
-                  </td>
-                  <td><a class="btn" href="#" data-bind="click: $root.removeParam">${ _('Delete') }</a></td>
-                </tr>
-              </tbody>
-            </table>
-
-            % if action_form['params'].errors:
-              <div class="alert alert-error">
-                ${ unicode(action_form['params'].errors) | n }
-              </div>
-            % endif
-
-            % if node_type in ('pig', 'hive'):
-              <button class="btn" data-bind="click: addParam">${ _('Add Param') }</button>
-            % endif
-            % if node_type in ('pig', 'shell', 'distcp'):
-              <button class="btn" data-bind="click: addArgument">${ _('Add Argument') }</button>
-            % endif
-            % if node_type in ('sqoop', 'ssh'):
-              <button class="btn" data-bind="click: addArg">${ _('Add Arg') }</button>
-            % endif
-            % if node_type in ('shell'):
-              <button class="btn" data-bind="click: addEnvVar">${ _('Add Env-Var') }</button>
-            % endif
-          </div>
-        </div>
-      % endif
-
-      % if 'job_properties' in action_form.fields:
-      <div class="control-group" rel="popover"
-          data-original-title="${ action_form['job_properties'].label }" data-content="${ action_form['job_properties'].help_text }">
-        <label class="control-label">${ _('Job Properties') }</label>
-        <div class="controls">
-          <table class="table-condensed designTable" data-bind="visible: properties().length > 0">
-            <thead>
-              <tr>
-                <th>${ _('Property name') }</th>
-                <th>${ _('Value') }</th>
-                <th/>
-              </tr>
-            </thead>
-            <tbody data-bind="foreach: properties">
-              <tr>
-                <td><input type="text" class="span4 required propKey" data-bind="value: name, uniqueName: false" /></td>
-                <td><input type="text" class="span4 required pathChooser" data-bind="fileChooser: $data, value: value, uniqueName: false" /></td>
-                <td><a class="btn" href="#" data-bind="click: $root.removeProp">${ _('Delete') }</a></td>
-              </tr>
-            </tbody>
-          </table>
-          % if action_form['job_properties'].errors:
-            <div class="row">
-              <div class="alert alert-error">
-                ${ unicode(action_form['job_properties'].errors) | n }
-              </div>
-            </div>
-          % endif
-
-          <button class="btn" data-bind="click: addProp">${ _('Add Property') }</button>
-        </div>
-      </div>
-      % endif
-
-      % if 'files' in action_form.fields:
-      <div class="control-group" rel="popover"
-        data-original-title="${ action_form['files'].label }" data-content="${ action_form['files'].help_text }">
-          <label class="control-label">${ _('Files') }</label>
-          <div class="controls">
-              <table class="table-condensed designTable" data-bind="visible: files().length > 0">
-                <tbody data-bind="foreach: files">
-                  <tr>
-                    <td><input type="text" class="span5 required pathChooser"
-                            data-bind="fileChooser: $data, value: name, uniqueName: false" />
-                    </td>
-                    <td><a class="btn" href="#" data-bind="click: $root.removeFile">${ _('Delete') }</a></td>
-                  </tr>
-                </tbody>
-              </table>
-              % if action_form['files'].errors:
-                <div class="alert alert-error">
-                  ${ unicode(action_form['files'].errors) | n }
-                </div>
-              % endif
-
-              <button class="btn" data-bind="click: addFile">${ _('Add File') }</button>
-          </div>
-      </div>
-      % endif
-
-      % if 'archives' in action_form.fields:
-      <div class="control-group" rel="popover"
-          data-original-title="${ action_form['archives'].label }" data-content="${ action_form['archives'].help_text }">
-        <label class="control-label">${ _('Archives') }</label>
-        <div class="controls">
-          <table class="table-condensed designTable" data-bind="visible: archives().length > 0">
-            <tbody data-bind="foreach: archives">
-              <tr>
-                <td>
-                  <input type="text" class="span5 required pathChooser"
-                      data-bind="fileChooser: $data, value: name, uniqueName: false" />
-                </td>
-                <td><a class="btn" href="#" data-bind="click: $root.removeArchive">${ _('Delete') }</a></td>
-              </tr>
-            </tbody>
-          </table>
-          % if action_form['archives'].errors:
-            <div class="alert alert-error">
-              ${ unicode(action_form['archives'].errors) | n }
-            </div>
-          % endif
-
-          <button class="btn" data-bind="click: addArchive">${ _('Add Archive') }</button>
-         </div>
-      </div>
-      % endif
-
-      % if 'job_xml' in action_form.fields:
-        ${ utils.render_field(action_form['job_xml']) }
-      % endif
-
-      </fieldset>
-
-      <div class="form-actions">
-        <a href="${ url('oozie:edit_workflow', workflow=workflow.id) }" class="btn">${ _('Cancel') }</a>
-        % if can_edit_action:
-          <button data-bind="click: submit" class="btn btn-primary">${ _('Save') }</button>
-        % endif
-      </div>
-    </form>
-  </div>
-  <div class="span1"></div>
-</div>
-
-
-<div id="chooseFile" class="modal hide fade">
-  <div class="modal-header">
-    <a href="#" class="close" data-dismiss="modal">&times;</a>
-    <h3>${ _('Choose a file') }</h3>
-  </div>
-  <div class="modal-body">
-    <div id="fileChooserModal"></div>
-  </div>
-  <div class="modal-footer"></div>
-</div>
-
-<link rel="stylesheet" href="/static/ext/css/jquery-ui-autocomplete-1.8.18.css" type="text/css" media="screen" title="no title" charset="utf-8" />
-<script src="/static/ext/js/knockout-2.1.0.js" type="text/javascript" charset="utf-8"></script>
-<script src="/static/ext/js/jquery/plugins/jquery-ui-autocomplete-1.8.18.min.js" type="text/javascript" charset="utf-8"></script>
-
-<style>
-  #fileChooserModal {
-    padding:14px;
-    height:270px;
-  }
-
-  #fileChooserModal > ul {
-    overflow-y:auto;
-  }
-
-  .designTable {
-    margin-left:0;
-  }
-
-  .designTable th, .designTable td {
-    padding-left: 0;
-  }
-
-  .designTable th {
-    text-align:left;
-  }
-</style>
-
-
-<script type="text/javascript" charset="utf-8">
-  $(document).ready(function(){
-    var propertiesHint = ${properties_hint};
-
-    // The files and archives are dictionaries in the model, because we
-    // can add and remove it the same way we add/remove properties.
-    // But the server expects them to be arrays. So we transform the
-    // two representations back and forth.
-    var arrayToDictArray = function(arr) {
-        var res = [ ];
-        for (var i in arr) {
-            res.push( { name: arr[i], dummy: "" } );
-        }
-        return res;
-    };
-
-    var dictArrayToArray = function(dictArray) {
-        var res = [ ];
-        for (var i in dictArray) {
-            res.push(dictArray[i]["name"]);
-        }
-        return res;
-    };
-
-    // Handles adding autocomplete to job properties.
-    // We need to propagate the selected value to knockoutjs.
-    var addAutoComplete = function(i, elem) {
-        $(elem).autocomplete({
-            source: propertiesHint,
-            select: function(event, ui) {
-                var context = ko.contextFor(this);
-                context.$data.name = ui.item.value;
-            }
-        });
-    };
-
-    var ViewModel = function(properties, files, archives, params, prepares) {
-        var self = this;
-
-        self.properties = ko.observableArray(properties);
-        self.files = ko.observableArray(files);
-        self.archives = ko.observableArray(archives);
-        self.params = ko.observableArray(params);
-        self.prepares = ko.observableArray(prepares);
-
-        self.addProp = function() {
-            self.properties.push({ name: "", value: "" });
-            $(".propKey:last").each(addAutoComplete);
-        };
-
-        self.removeProp = function(val) {
-            self.properties.remove(val);
-        };
-
-        self.addParam = function() {
-            self.params.push({ value: "", type: "param" });
-        };
-
-        self.addArgument = function() {
-            self.params.push({ value: "", type: "argument" });
-        };
-
-        self.addArg = function() {
-            self.params.push({ value: "", type: "arg" });
-        };
-
-        self.addEnvVar = function() {
-            self.params.push({ value: "", type: "env-var" });
-        };
-
-        self.removeParam = function(val) {
-            self.params.remove(val);
-        };
-
-        self.addPrepareDelete = function() {
-            self.prepares.push({ value: "", type: "delete" });
-        };
-
-        self.addPrepareMkdir = function() {
-            self.prepares.push({ value: "", type: "mkdir" });
-        };
-
-        self.removePrepare = function(val) {
-            self.prepares.remove(val);
-        };
-
-        self.addFile = function() {
-            self.files.push({ name: "", dummy: "" });
-        };
-
-        self.removeFile = function(val) {
-            self.files.remove(val);
-        };
-
-        self.addArchive = function() {
-            self.archives.push({ name: "", dummy: "" });
-        };
-
-        self.removeArchive = function(val) {
-            self.archives.remove(val);
-        };
-
-        self.submit = function(form) {
-            var form = $("#actionForm");
-            var files_arr = dictArrayToArray(ko.toJS(self.files));
-            var archives_arr = dictArrayToArray(ko.toJS(self.archives));
-
-            // Beware: dirty
-            $("<input>").attr("type", "hidden")
-                .attr("name", "job_properties")
-                .attr("value", ko.utils.stringifyJson(self.properties))
-                .appendTo(form);
-            $("<input>").attr("type", "hidden")
-                .attr("name", "files")
-                .attr("value", JSON.stringify(files_arr))
-                .appendTo(form);
-            $("<input>").attr("type", "hidden")
-                .attr("name", "archives")
-                .attr("value", JSON.stringify(archives_arr))
-                .appendTo(form);
-            $("<input>").attr("type", "hidden")
-                .attr("name", "params")
-                .attr("value", ko.utils.stringifyJson(self.params))
-                .appendTo(form);
-            $("<input>").attr("type", "hidden")
-                .attr("name", "prepares")
-                .attr("value", ko.utils.stringifyJson(self.prepares))
-                .appendTo(form);
-
-            form.submit();
-        };
-      };
-
-    var viewModel = new ViewModel(
-              ${ job_properties },
-              arrayToDictArray(${ files }),
-              arrayToDictArray(${ archives }),
-              ${ params },
-              ${ prepares });
-
-    ko.bindingHandlers.fileChooser = {
-          init: function(element, valueAccessor, allBindings, model) {
-          var self = $(element);
-          self.after(getFileBrowseButton(self));
-      }
-    };
-
-    ko.applyBindings(viewModel);
-
-    $("input[name='job_xml']").addClass("pathChooser").after(getFileBrowseButton($("input[name='job_xml']")));
-    $("input[name='jar_path']").addClass("pathChooser").after(getFileBrowseButton($("input[name='jar_path']")));
-
-    function getFileBrowseButton(inputElement) {
-      return $("<button>").addClass("btn").addClass("fileChooserBtn").text("..").click(function(e){
-        e.preventDefault();
-        // check if it's a relative path
-        var pathAddition = "";
-        if ($.trim(inputElement.val()) != ""){
-          $.getJSON("/filebrowser/chooser${ workflow.deployment_dir }" + inputElement.val(), function (data) {
-            pathAddition = "${ workflow.deployment_dir }";
-            callFileChooser();
-          }).error(function(){
-            callFileChooser();
-          });
-        }
-        else {
-          callFileChooser();
-        }
-
-        function callFileChooser() {
-          $("#fileChooserModal").jHueFileChooser({
-            onFileChoose: function(filePath) {
-              if (filePath.indexOf("${ workflow.deployment_dir }") > -1){
-                filePath = filePath.substring("${ workflow.deployment_dir }".length);
-                if (filePath == ""){
-                  filePath = "/";
-                }
-              }
-              inputElement.val(filePath);
-              inputElement.change();
-              $("#chooseFile").modal("hide");
-            },
-            createFolder: false,
-            initialPath: $.trim(inputElement.val()) != "" ? pathAddition + inputElement.val() : "${ workflow.deployment_dir }",
-            errorRedirectPath: "${ workflow.deployment_dir }"
-          });
-          $("#chooseFile").modal("show");
-        }
-      });
-    }
-
-    $(".propKey").each(addAutoComplete);
-
-    $("*[rel=popover]").popover({
-      placement: 'right',
-      trigger: 'hover'
-    });
-  });
-</script>
-
-${commonfooter(messages)}

+ 0 - 123
apps/oozie/src/oozie/templates/editor/edit_workflow_fork.mako

@@ -1,123 +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.
-
-<%!
-  from desktop.views import commonheader, commonfooter
-  from django.utils.translation import ugettext as _
-%>
-
-<%namespace name="layout" file="../navigation-bar.mako" />
-<%namespace name="utils" file="../utils.inc.mako" />
-
-${ commonheader(_("Oozie App"), "oozie", user, "100px") }
-${ layout.menubar(section='workflows') }
-
-
-<div class="container-fluid">
-  <h1>${ _('Workflow') } <a href="${ url('oozie:edit_workflow', workflow=workflow.id) }">${ workflow.name }</a>
-    : ${ fork.node_type.title() } ${ fork }
-  </h1>
-
-  <br/>
-
-  <form class="form-horizontal" action="${ url('oozie:edit_workflow_fork', action=fork.id) }" method="POST">
-    <fieldset>
-
-      % if not fork.has_decisions():
-        <div class="control-group">
-          <label class="control-label"></label>
-          <div class="controls span8">
-            <div class="alert alert-info">
-              ${ _('You can convert this') } <code>${ _('Fork') }</code> ${ _('into a') } <code>${ _('Decision') }</code> ${ _('switch by adding some conditions on the branching.') }
-            </div>
-          </div>
-        </div>
-      % endif
-
-      <div class="control-group">
-        <label class="control-label"></label>
-        <div class="controls span8">
-          <div>${ _('Examples of predicates:') }</div>
-          <div class="well">
-            ${"${"} fs:fileSize(secondjobOutputDir) gt 10 * GB }
-            <br/>
-            ${"${"} hadoop:counters('secondjob')[RECORDS][REDUCE_OUT] lt 1000000 }
-          </div>
-        </div>
-      </div>
-
-      ${ link_formset.management_form }
-
-      <div class="control-group">
-        <label class="control-label"></label>
-        <div class="controls">
-          <table class="table-condensed">
-            <thead>
-              <tr>
-                <th>${ _('Predicate') }</th>
-                <th/>
-                <th>${ _('Action') }</th>
-              </tr>
-            </thead>
-            <tbody>
-              % for form in link_formset.forms:
-                  % for hidden in form.hidden_fields():
-                    ${ hidden }
-                  % endfor
-              <tr>
-                <td>
-                  ${ utils.render_field(form['comment']) }
-                </td>
-                <td class="center">
-                  ${ _('go to') }
-                </td>
-                <td class="right">
-                  <a href="${ form.instance.child.get_full_node().get_edit_link() }" class="span3">${ form.instance.child }</a>
-                </td>
-              </tr>
-              % endfor
-                <tr>
-                  <td>
-             <div class="control-group">
-                <label class="control-label"></label>
-                <div class="controls span8">
-                    <div>${ _('default') }</div>
-                </div>
-              </div>
-                  </td>
-                  <td class="center">
-                  ${ _('go to') }
-                  </td>
-                  <td class="right">
-                    ${ utils.render_field(default_link_form['child']) }
-                  </td>
-                </tr>
-            </tbody>
-          </table>
-        </div>
-      </div>
-
-    </fieldset>
-
-    <div class="form-actions">
-      <a href="${ url('oozie:edit_workflow', workflow=workflow.id) }" class="btn">${ _('Cancel') }</a>
-      <button data-bind="click: submit" class="btn btn-primary">${ _('Save') }</button>
-    </div>
-
-  </form>
-</div>
-
-${commonfooter(messages)}

+ 0 - 88
apps/oozie/src/oozie/templates/editor/gen/workflow-graph-editable.xml.mako

@@ -1,88 +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.
-
-<%!
-  from django.utils.translation import ugettext as _
-%>
-
-<%namespace name="graph" file="workflow-graph.xml.mako" />
-<%namespace name="utils" file="../../utils.inc.mako" />
-
-
-<%def name="print_editable_node(form)">
-  <%
-    node = form.instance.get_full_node()
-  %>
-  <div class="row-fluid">
-    % if not node.is_visible():
-       <div class="hide">
-           ${ form }
-       </div>
-    % else:
-      <div class="span12 action">
-        % for hidden in form.hidden_fields():
-          ${ hidden }
-        % endfor
-        <div class="row-fluid">
-            % if node.is_editable():
-            <div class="span10 action-link" data-edit="${ node.get_edit_link() }" title="${ _('Edit') }">
-          % else:
-            <div class="span10">
-          % endif
-              <span class="label label-info"><b>${ node }</b></span>
-          </div>
-            <div class="span2"></div>
-        </div>
-        <div class="row-fluid">
-          % if node.is_editable():
-            <div class="span10 action-link" data-edit="${ node.get_edit_link() }" title="${ _('Edit') }">
-          % else:
-            <div class="span10">
-          % endif
-
-          <span class="">${ node.node_type }</span>
-
-          <br/>
-          ${ node.description }
-        </div>
-        <div class="span2">
-          % if node.can_move():
-          <button class="btn" name="move_up_action" value="${ node.id }" title="${ _('Move Up') }" ${ utils.if_false(node.can_move_up(), 'disabled') }>
-            <i class="icon-arrow-up"></i>
-          </button>
-          <button class="btn" name="move_down_action" value="${ node.id }" title="${ _('Move Down') }" ${ utils.if_false(node.can_move_down(), 'disabled') }>
-            <i class="icon-arrow-down"></i>
-          </button>
-          % endif
-        </div>
-        </div>
-        <div class="row-fluid">
-          % if node.can_move():
-          <div class="span10">
-            <button class="btn" name="clone_action" value="${ node.id }" title="${ _('Clone') }"><i class="icon-retweet"></i></button>
-            <button class="btn" name="delete_action" value="${ node.id }" title="${ _('Delete') }"><i class="icon-remove"></i></button>
-          </div>
-          <div class="span2">
-          </div>
-          % endif
-        </div>
-      </div>
-    % endif
-  </div>
-</%def>
-
-${ graph.display_graph(nodes, print_editable_node) }
-

+ 0 - 71
apps/oozie/src/oozie/templates/editor/gen/workflow-graph-readonly.xml.mako

@@ -1,71 +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.
-
-<%!
-from django.utils.translation import ugettext as _
-%>
-
-<%namespace name="graph" file="workflow-graph.xml.mako" />
-<%namespace name="utils" file="../../utils.inc.mako" />
-
-
-<%def name="print_readonly_node(form)">
-<%
-  node = form.instance.get_full_node()
-%>
-<div class="row-fluid">
-  % if not node.is_visible():
-     <div class="hide">
-         ${ form }
-     </div>
-  % else:
-    <div class="span12 action">
-      % for hidden in form.hidden_fields():
-        ${ hidden }
-      % endfor
-      <div class="row-fluid">
-          % if node.is_editable():
-          <div class="span10 action-link" data-edit="${ node.get_edit_link() }" title="${ _('View') }">
-        % else:
-          <div class="span10">
-        % endif
-            <span class="label label-info"><b>${ node }</b></span>
-        </div>
-          <div class="span2"></div>
-      </div>
-      <div class="row-fluid">
-        % if node.is_editable():
-          <div class="span10 action-link" data-edit="${ node.get_edit_link() }" title="${ _('View') }">
-        % else:
-          <div class="span10">
-        % endif
-
-        <span class="">${ node.node_type }</span>
-
-        <br/>
-        ${ node.description }
-      </div>
-      <div class="span2">
-      </div>
-      </div>
-      <div class="row-fluid">
-      </div>
-    </div>
-  % endif
-</div>
-</%def>
-
-${ graph.display_graph(nodes, print_readonly_node) }

+ 48 - 0
apps/oozie/src/oozie/templates/editor/workflow_utils.mako

@@ -0,0 +1,48 @@
+## 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.
+
+<%!
+  from django.utils.translation import ugettext as _
+%>
+
+
+<%def name="key_value_field(field, javascript_attrs={})">
+  <div id="${ javascript_attrs['name'] }" class="control-group ko-${ javascript_attrs['name'] }" rel="popover"
+      data-original-title="${ field.label }" data-content="${ field.help_text }">
+    <label class="control-label">${ field.label }</label>
+
+    <div class="controls">
+      <table class="table-condensed designTable" data-bind="visible: ${ javascript_attrs['name'] }().length > 0" style="text-align: left;">
+        <thead>
+          <tr>
+            <th>${ _('Name') }</th>
+            <th>${ _('Value') }</th>
+            <th/>
+          </tr>
+        </thead>
+        <tbody data-bind="foreach: ${ javascript_attrs['name'] }">
+          <tr>
+            <td><input type="text" class="required" data-bind="value: name" /></td>
+            <td><input type="text" class="required" data-bind="value: value" /></td>
+            <td><a class="btn btn-small" href="#" data-bind="click: function(data, event) { ${ javascript_attrs['remove'] }.call($root, data, event) }">${ _('Delete') }</a></td>
+          </tr>
+        </tbody>
+      </table>
+
+      <button class="btn" data-bind="click: function(data, event) { ${ javascript_attrs['add'] }.call($root, data, event) }">${ _('Add') }</button>
+    </div>
+  </div>
+</%def>

+ 2 - 1
apps/oozie/src/oozie/templates/utils.inc.mako

@@ -159,7 +159,7 @@
 </%def>
 
 
-<%def name="render_field(field, show_label=True)">
+<%def name="render_field(field, show_label=True, extra_attrs={})">
   % if not field.is_hidden:
     <% group_class = field.errors and "error" or "" %>
     <div class="control-group ${group_class}"
@@ -168,6 +168,7 @@
         <label class="control-label">${ field.label | h }</label>
       % endif
       <div class="controls">
+        <% field.field.widget.attrs.update(extra_attrs) %>
         ${ field }
         % if field.errors:
           <span class="help-inline">${ unicode(field.errors) }</span>

File diff ditekan karena terlalu besar
+ 375 - 413
apps/oozie/src/oozie/tests.py


+ 7 - 8
apps/oozie/src/oozie/urls.py

@@ -20,8 +20,14 @@ from django.conf.urls.defaults import patterns, url
 
 IS_URL_NAMESPACED = True
 
-
 urlpatterns = patterns(
+  'oozie.views.api',
+
+  url(r'^workflows/(?P<workflow>\d+)$', 'workflow', name='workflow'),
+  url(r'^workflows/(?P<workflow>\d+)/save$', 'workflow_save', name='workflow_save'),
+)
+
+urlpatterns += patterns(
   'oozie.views.editor',
 
   url(r'^list_workflows/$', 'list_workflows', name='list_workflows'),
@@ -33,14 +39,7 @@ urlpatterns = patterns(
   url(r'^schedule_workflow/(?P<workflow>\d+)$', 'schedule_workflow', name='schedule_workflow'),
   url(r'^resubmit_workflow/(?P<oozie_wf_id>[-\w]+)$', 'resubmit_workflow', name='resubmit_workflow'),
 
-  url(r'^new_action/(?P<workflow>\d+)/(?P<node_type>\w+)/(?P<parent_action_id>\d+)$', 'new_action', name='new_action'),
   url(r'^import_action/(?P<workflow>\d+)/(?P<parent_action_id>\d+)$', 'import_action', name='import_action'),
-  url(r'^edit_action/(?P<action>\d+)$', 'edit_action', name='edit_action'),
-  url(r'^edit_workflow_fork/(?P<action>\d+)$', 'edit_workflow_fork', name='edit_workflow_fork'),
-  url(r'^delete_action/(?P<action>\d+)$', 'delete_action', name='delete_action'),
-  url(r'^clone_action/(?P<action>\d+)$', 'clone_action', name='clone_action'),
-  url(r'^move_up_action/(?P<action>\d+)$', 'move_up_action', name='move_up_action'),
-  url(r'^move_down_action/(?P<action>\d+)$', 'move_down_action', name='move_down_action'),
 
   url(r'^list_coordinators/(?P<workflow_id>[-\w]+)?$', 'list_coordinators', name='list_coordinators'),
   url(r'^create_coordinator/(?P<workflow>[-\w]+)?$', 'create_coordinator', name='create_coordinator'),

+ 44 - 0
apps/oozie/src/oozie/utils.py

@@ -0,0 +1,44 @@
+#!/usr/bin/env python
+# 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.
+
+
+def model_to_dict(model):
+  from django.db import models
+  from datetime import datetime
+  dictionary = {}
+  for field in model._meta.fields:
+    attr = getattr(model, field.name, None)
+    if isinstance(attr, models.Model):
+      dictionary[field.name] = attr.id
+    elif isinstance(attr, datetime):
+      dictionary[field.name] = str(attr)
+    else:
+      dictionary[field.name] = attr
+  return dictionary
+
+
+def workflow_to_dict(workflow):
+  workflow_dict = model_to_dict(workflow)
+  node_list = [node.get_full_node() for node in workflow.node_list]
+  nodes = [model_to_dict(node) for node in node_list]
+
+  for index, node in enumerate(node_list):
+    nodes[index]['child_links'] = [model_to_dict(link) for link in node.get_all_children_links()]
+  
+  workflow_dict['nodes'] = nodes
+
+  return workflow_dict

+ 222 - 0
apps/oozie/src/oozie/views/api.py

@@ -0,0 +1,222 @@
+#!/usr/bin/env python
+# 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.
+
+try:
+  import json
+except ImportError:
+  import simplejson as json
+import logging
+
+from django.http import HttpResponse
+from django.utils.translation import ugettext as _
+
+from desktop.lib.exceptions import StructuredException
+
+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
+
+
+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.")
+
+  validate_json_links(json['child_links'])
+
+
+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 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."
+
+
+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 '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'])
+
+
+def get_or_create_node(workflow, node_data):
+  node = None
+  id = str(node_data['id'])
+  separator_index = id.find(':')
+
+  if separator_index == -1:
+    return Node.objects.get(id=id, workflow=workflow).get_full_node()
+
+  node_type = id[0:separator_index]
+  node_model = NODE_TYPES.get(node_type, None)
+  if node_model:
+    node = node_model(workflow=workflow, node_type=node_data['node_type'])
+  else:
+    raise StructuredException(code="INVALID_REQUEST_ERROR", message=_('Could not find node of type'), data=node_data, error_code=500)
+  node.save()
+  return node
+
+
+def update_workflow(json_workflow):
+  workflow = Workflow.objects.get(id=json_workflow['id'])
+
+  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])
+
+  workflow.save()
+
+  return workflow
+
+
+def update_workflow_nodes(workflow, json_nodes, id_map):
+  validate_json_nodes(json_nodes)
+
+  nodes = []
+
+  for json_node in json_nodes:
+    node = get_or_create_node(workflow, json_node)
+    if node.node_type == 'fork' and json_node['node_type'] == 'decision':
+      node = node.convert_to_decision()
+
+    id_map[str(json_node['id'])] = node.id
+
+    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])
+    
+    node.workflow = workflow
+    node.save()
+
+    # Keep track of nodes in order of received list
+    # so that we may iterate over them again in the same order
+    # when we handle links
+    nodes.append(node)
+
+  # Delete unused nodes from workflow
+  old_nodes = Node.objects.filter(workflow=workflow).exclude(id__in=map(lambda x: x.id, nodes))
+  for node in old_nodes:
+    node.get_full_node().delete()
+
+  return nodes
+
+
+# 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')))
+
+  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)
+
+  json_nodes = json_workflow['nodes']
+  id_map = {}
+
+  workflow = update_workflow(json_workflow)
+  nodes = update_workflow_nodes(workflow, json_nodes, id_map)
+
+  # Update links
+  index = 0
+  for json_node in json_nodes:
+    child_links = json_node['child_links']
+    Link.objects.filter(parent=nodes[index]).delete()
+    
+    for child_link in child_links:
+      link = Link()
+      link.id = getattr(child_link, 'id', None)
+      link.name = child_link['name']
+
+      id = str(child_link['parent'])
+      link.parent = Node.objects.get(id=id_map[id])
+
+      id = str(child_link['child'])
+      link.child = Node.objects.get(id=id_map[id])
+
+      link.comment = child_link.get('comment', '')
+
+      link.save()
+
+    index += 1
+
+  # Make sure workflow is shared
+  Workflow.objects.check_workspace(workflow, request.fs)
+
+  return _workflow(request, workflow=workflow)
+
+
+def _workflow(request, workflow):
+  response = {'status': -1, 'data': 'None'}
+
+  workflow_dict = model_to_dict(workflow)
+  node_list = [node.get_full_node() for node in workflow.node_list]
+  nodes = [model_to_dict(node) for node in node_list]
+
+  for index in range(0, len(node_list)):
+    nodes[index]['child_links'] = [model_to_dict(link) for link in node_list[index].get_all_children_links()]
+  
+  workflow_dict['nodes'] = nodes
+
+  response['status'] = 0
+  response['data'] = workflow_dict
+  return HttpResponse(json.dumps(response), mimetype="application/json")
+
+
+@check_job_access_permission(exception_class=(lambda x: StructuredException(code="UNAUTHORIZED_REQUEST_ERROR", message=x, data=None, error_code=401)))
+def workflow(request, workflow):
+  return _workflow(request, workflow)

+ 25 - 275
apps/oozie/src/oozie/views/editor.py

@@ -25,10 +25,10 @@ import logging
 from django.core.urlresolvers import reverse
 from django.db.models import Q
 from django.forms.formsets import formset_factory
-from django.forms.models import inlineformset_factory, modelformset_factory
+from django.forms.models import inlineformset_factory
 from django.http import HttpResponse
 from django.shortcuts import redirect
-from django.utils.functional import curry, wraps
+from django.utils.functional import curry
 from django.utils.translation import ugettext as _
 
 from desktop.lib.django_util import render, extract_field_data
@@ -39,12 +39,14 @@ from jobsub.models import OozieDesign
 from liboozie.submittion import Submission
 
 from oozie.conf import SHARE_JOBS
+from oozie.decorators import check_job_access_permission, check_job_edition_permission,\
+                             check_action_access_permission, check_action_edition_permission,\
+                             check_dataset_access_permission, check_dataset_edition_permission
 from oozie.import_jobsub import convert_jobsub_design
 from oozie.management.commands import oozie_setup
-from oozie.models import Job, Workflow, Node, Link, History, Coordinator,\
-  Mapreduce, Java, Streaming, Dataset, DataInput, DataOutput,\
-  _STD_PROPERTIES_JSON
-from oozie.forms import NodeForm, WorkflowForm, CoordinatorForm, DatasetForm,\
+from oozie.models import Job, Workflow, History, Coordinator,Mapreduce, Java, Streaming,\
+                         Dataset, DataInput, DataOutput, ACTION_TYPES
+from oozie.forms import WorkflowForm, CoordinatorForm, DatasetForm,\
   DataInputForm, DataInputSetForm, DataOutputForm, DataOutputSetForm, LinkForm,\
   DefaultLinkForm, design_form_by_type, ImportJobsubDesignForm, ParameterForm
 
@@ -52,123 +54,6 @@ from oozie.forms import NodeForm, WorkflowForm, CoordinatorForm, DatasetForm,\
 LOG = logging.getLogger(__name__)
 
 
-
-def check_job_access_permission(view_func):
-  """
-  Decorator ensuring that the user has access to the workflow or coordinator.
-
-  Arg: 'workflow' or 'coordinator' id.
-  Return: the workflow of coordinator or raise an exception
-
-  Notice: its gets an id in input and returns the full object in output (not an id).
-  """
-  def decorate(request, *args, **kwargs):
-    if 'workflow' in kwargs:
-      job_type = 'workflow'
-    else:
-      job_type = 'coordinator'
-
-    job = kwargs.get(job_type)
-    if job is not None:
-      job = Job.objects.is_accessible_or_exception(request, job)
-    kwargs[job_type] = job
-
-    return view_func(request, *args, **kwargs)
-  return wraps(view_func)(decorate)
-
-
-def check_job_edition_permission(authorize_get=False):
-  """
-  Decorator ensuring that the user has the permissions to modify a workflow or coordinator.
-
-  Need to appear below @check_job_access_permission
-  """
-  def inner(view_func):
-    def decorate(request, *args, **kwargs):
-      if 'workflow' in kwargs:
-        job_type = 'workflow'
-      else:
-        job_type = 'coordinator'
-
-      job = kwargs.get(job_type)
-      if job is not None and not (authorize_get and request.method == 'GET'):
-        Job.objects.can_edit_or_exception(request, job)
-
-      return view_func(request, *args, **kwargs)
-    return wraps(view_func)(decorate)
-  return inner
-
-
-def check_action_access_permission(view_func):
-  """
-  Decorator ensuring that the user has access to the workflow action.
-
-  Arg: 'workflow action' id.
-  Return: the workflow action or raise an exception
-
-  Notice: its gets an id in input and returns the full object in output (not an id).
-  """
-  def decorate(request, *args, **kwargs):
-    action_id = kwargs.get('action')
-    action = Node.objects.get(id=action_id).get_full_node()
-    Job.objects.is_accessible_or_exception(request, action.workflow.id)
-    kwargs['action'] = action
-
-    return view_func(request, *args, **kwargs)
-  return wraps(view_func)(decorate)
-
-
-def check_action_edition_permission(view_func):
-  """
-  Decorator ensuring that the user has the permissions to modify a workflow action.
-
-  Need to appear below @check_action_access_permission
-  """
-  def decorate(request, *args, **kwargs):
-    action = kwargs.get('action')
-    Job.objects.can_edit_or_exception(request, action.workflow)
-
-    return view_func(request, *args, **kwargs)
-  return wraps(view_func)(decorate)
-
-
-def check_dataset_access_permission(view_func):
-  """
-  Decorator ensuring that the user has access to dataset.
-
-  Arg: 'dataset'.
-  Return: the dataset or raise an exception
-
-  Notice: its gets an id in input and returns the full object in output (not an id).
-  """
-  def decorate(request, *args, **kwargs):
-    dataset = kwargs.get('dataset')
-    if dataset is not None:
-      dataset = Dataset.objects.is_accessible_or_exception(request, dataset)
-    kwargs['dataset'] = dataset
-
-    return view_func(request, *args, **kwargs)
-  return wraps(view_func)(decorate)
-
-
-def check_dataset_edition_permission(authorize_get=False):
-  """
-  Decorator ensuring that the user has the permissions to modify a dataset.
-  A dataset can be edited if the coordinator that owns the dataset can be edited.
-
-  Need to appear below @check_dataset_access_permission
-  """
-  def inner(view_func):
-    def decorate(request, *args, **kwargs):
-      dataset = kwargs.get('dataset')
-      if dataset is not None and not (authorize_get and request.method == 'GET'):
-        Job.objects.can_edit_or_exception(request, dataset.coordinator)
-
-      return view_func(request, *args, **kwargs)
-    return wraps(view_func)(decorate)
-  return inner
-
-
 def list_workflows(request):
   show_setup_app = True
   data = Workflow.objects
@@ -229,58 +114,27 @@ def create_workflow(request):
   })
 
 
-@check_job_access_permission
+@check_job_access_permission()
 def edit_workflow(request, workflow):
-  WorkflowFormSet = inlineformset_factory(Workflow, Node, form=NodeForm, max_num=0, can_order=False, can_delete=False)
   history = History.objects.filter(submitter=request.user, job=workflow).order_by('-submission_date')
 
-  if request.method == 'POST' and Job.objects.can_edit_or_exception(request, workflow):
-    try:
-      workflow_form = WorkflowForm(request.POST, instance=workflow)
-      actions_formset = WorkflowFormSet(request.POST, request.FILES, instance=workflow)
-
-      if 'clone_action' in request.POST: return clone_action(request, action=request.POST['clone_action'])
-      if 'delete_action' in request.POST: return delete_action(request, action=request.POST['delete_action'])
-      if 'move_up_action' in request.POST: return move_up_action(request, action=request.POST['move_up_action'])
-      if 'move_down_action' in request.POST: return move_down_action(request, action=request.POST['move_down_action'])
-
-      if workflow_form.is_valid() and actions_formset.is_valid():
-        workflow = workflow_form.save()
-        actions_formset.save()
-
-        if workflow.has_cycle():
-          raise PopupException(_('Sorry, this operation is not creating a cycle which would break the workflow.'))
-
-        Workflow.objects.check_workspace(workflow, request.fs)
-
-        request.info(_("Workflow saved!"))
-        return redirect(reverse('oozie:edit_workflow', kwargs={'workflow': workflow.id}))
-    except Exception, e:
-      request.error(_('Sorry, this operation is not supported: %(error)s') % {'error': e})
-
   workflow_form = WorkflowForm(instance=workflow)
-  actions_formset = WorkflowFormSet(instance=workflow)
 
-  graph_options = {}
   user_can_edit_job = workflow.is_editable(request.user)
-  if not user_can_edit_job:
-    graph_options = {'template': 'editor/gen/workflow-graph-readonly.xml.mako'}
-
-  graph = workflow.gen_graph(actions_formset.forms, **graph_options)
 
   return render('editor/edit_workflow.mako', request, {
     'workflow_form': workflow_form,
     'workflow': workflow,
-    'actions_formset': actions_formset,
-    'graph': graph,
     'history': history,
     'user_can_edit_job': user_can_edit_job,
-    'parameters': extract_field_data(workflow_form['parameters']),
-    'job_properties': extract_field_data(workflow_form['job_properties'])
+    'job_properties': extract_field_data(workflow_form['job_properties']),
+    'link_form': LinkForm(),
+    'default_link_form': DefaultLinkForm(action=workflow.start),
+    'action_forms': [(node_type, design_form_by_type(node_type)()) for node_type in ACTION_TYPES.iterkeys()]
   })
 
 
-@check_job_access_permission
+@check_job_access_permission()
 @check_job_edition_permission()
 def delete_workflow(request, workflow):
   if request.method != 'POST':
@@ -292,7 +146,7 @@ def delete_workflow(request, workflow):
   return redirect(reverse('oozie:list_workflows'))
 
 
-@check_job_access_permission
+@check_job_access_permission()
 def clone_workflow(request, workflow):
   if request.method != 'POST':
     raise PopupException(_('A POST request is required.'))
@@ -304,7 +158,7 @@ def clone_workflow(request, workflow):
   return HttpResponse(json.dumps(response), mimetype="application/json")
 
 
-@check_job_access_permission
+@check_job_access_permission()
 def submit_workflow(request, workflow):
   ParametersFormSet = formset_factory(ParameterForm, extra=0)
 
@@ -361,7 +215,7 @@ def resubmit_workflow(request, oozie_wf_id):
   return redirect(reverse('oozie:list_oozie_workflow', kwargs={'job_id': job_id}))
 
 
-@check_job_access_permission
+@check_job_access_permission()
 def schedule_workflow(request, workflow):
   if Coordinator.objects.filter(workflow=workflow).exists():
     request.info(_('You already have some coordinators for this workflow. Please submit one or create a new one.'))
@@ -370,71 +224,7 @@ def schedule_workflow(request, workflow):
     return create_coordinator(request, workflow=workflow.id)
 
 
-@check_job_access_permission
-@check_job_edition_permission()
-def new_action(request, workflow, node_type, parent_action_id):
-  ActionForm = design_form_by_type(node_type)
-
-  if request.method == 'POST':
-    action_form = ActionForm(request.POST)
-
-    if action_form.is_valid():
-      action = action_form.save(commit=False)
-      action.node_type = node_type
-      action.workflow = workflow
-      action.save()
-
-      workflow.add_action(action, parent_action_id)
-
-      return redirect(reverse('oozie:edit_workflow', kwargs={'workflow': workflow.id}))
-  else:
-    action_form = ActionForm()
-
-  return render('editor/edit_workflow_action.mako', request, {
-      'workflow': workflow,
-      'job_properties': 'job_properties' in action_form.fields and extract_field_data(action_form['job_properties']) or '[]',
-      'files': 'files' in action_form.fields and extract_field_data(action_form['files']) or '[]',
-      'archives': 'archives' in action_form.fields and extract_field_data(action_form['archives']) or '[]',
-      'params': 'params' in action_form.fields and extract_field_data(action_form['params']) or '[]',
-      'prepares': 'prepares' in action_form.fields and extract_field_data(action_form['prepares']) or '[]',
-      'action_form': action_form,
-      'node_type': node_type,
-      'properties_hint': _STD_PROPERTIES_JSON,
-      'form_url': reverse('oozie:new_action', kwargs={'workflow': workflow.id,
-                                                      'node_type': node_type,
-                                                      'parent_action_id': parent_action_id}),
-      'can_edit_action': True,
-    })
-
-
-@check_action_access_permission
-def edit_action(request, action):
-  ActionForm = design_form_by_type(action.node_type)
-
-  if request.method == 'POST' and Job.objects.can_edit_or_exception(request, action.workflow):
-    action_form = ActionForm(request.POST, instance=action)
-    if action_form.is_valid():
-      action = action_form.save()
-      return redirect(reverse('oozie:edit_workflow', kwargs={'workflow': action.workflow.id}))
-  else:
-    action_form = ActionForm(instance=action)
-
-  return render('editor/edit_workflow_action.mako', request, {
-    'workflow': action.workflow,
-    'job_properties': 'job_properties' in action_form.fields and extract_field_data(action_form['job_properties']) or '[]',
-    'files': 'files' in action_form.fields and extract_field_data(action_form['files']) or '[]',
-    'archives': 'archives' in action_form.fields and extract_field_data(action_form['archives']) or '[]',
-    'params': 'params' in action_form.fields and extract_field_data(action_form['params']) or '[]',
-    'prepares': 'prepares' in action_form.fields and extract_field_data(action_form['prepares']) or '[]',
-    'action_form': action_form,
-    'node_type': action.node_type,
-    'properties_hint': _STD_PROPERTIES_JSON,
-    'form_url': reverse('oozie:edit_action', kwargs={'action': action.id}),
-    'can_edit_action': action.workflow.is_editable(request.user)
-  })
-
-
-@check_job_access_permission
+@check_job_access_permission()
 def import_action(request, workflow, parent_action_id):
   available_actions = OozieDesign.objects.all()
 
@@ -463,46 +253,6 @@ def import_action(request, workflow, parent_action_id):
     'form_url': reverse('oozie:import_action', kwargs={'workflow': workflow.id, 'parent_action_id': parent_action_id}),
   })
 
-@check_action_access_permission
-@check_action_edition_permission
-def edit_workflow_fork(request, action):
-  fork = action
-  LinkFormSet = modelformset_factory(Link, form=LinkForm, max_num=0)
-
-  if request.method == 'POST':
-    link_formset = LinkFormSet(request.POST)
-    default_link_form = DefaultLinkForm(request.POST, action=fork)
-
-    if link_formset.is_valid():
-      is_decision = fork.has_decisions()
-      link_formset.save()
-      if not is_decision and fork.has_decisions():
-        default_link = default_link_form.save(commit=False)
-        default_link.parent = fork
-        default_link.name = 'default'
-        default_link.comment = 'default'
-        default_link.save()
-        fork.convert_to_decision()
-      fork.update_description()
-
-      return redirect(reverse('oozie:edit_workflow', kwargs={'workflow': fork.workflow.id}))
-  else:
-    if filter(lambda link: link.child.id != action.workflow.end.id,
-              [link for link in fork.get_child_join().get_children_links()]):
-      raise PopupException(_('Sorry, this Fork has some other actions below its Join and cannot be converted. '
-                             'Please delete the nodes below the Join.'))
-
-    link_formset = LinkFormSet(queryset=fork.get_children_links())
-    default_link = Link(parent=fork, name='default', comment='default')
-    default_link_form = DefaultLinkForm(action=fork, instance=default_link)
-
-  return render('editor/edit_workflow_fork.mako', request, {
-    'workflow': fork.workflow,
-    'fork': fork,
-    'link_formset': link_formset,
-    'default_link_form': default_link_form,
-  })
-
 
 @check_action_access_permission
 @check_action_edition_permission
@@ -548,7 +298,7 @@ def move_down_action(request, action):
     raise PopupException(_('A POST request is required.'))
 
 
-@check_job_access_permission
+@check_job_access_permission()
 def create_coordinator(request, workflow=None):
   if workflow is not None:
     coordinator = Coordinator(owner=request.user, schema_version="uri:oozie:coordinator:0.1", workflow=workflow)
@@ -572,7 +322,7 @@ def create_coordinator(request, workflow=None):
   })
 
 
-@check_job_access_permission
+@check_job_access_permission()
 @check_job_edition_permission()
 def delete_coordinator(request, coordinator):
   if request.method != 'POST':
@@ -585,7 +335,7 @@ def delete_coordinator(request, coordinator):
   return redirect(reverse('oozie:list_coordinators'))
 
 
-@check_job_access_permission
+@check_job_access_permission()
 @check_job_edition_permission(True)
 def edit_coordinator(request, coordinator):
   history = History.objects.filter(submitter=request.user, job=coordinator).order_by('-submission_date')
@@ -643,7 +393,7 @@ def edit_coordinator(request, coordinator):
   })
 
 
-@check_job_access_permission
+@check_job_access_permission()
 @check_job_edition_permission()
 def create_coordinator_dataset(request, coordinator):
   """Returns {'status' 0/1, data:html or url}"""
@@ -698,7 +448,7 @@ def edit_coordinator_dataset(request, dataset):
   }, force_template=True)
 
 
-@check_job_access_permission
+@check_job_access_permission()
 @check_job_edition_permission()
 def create_coordinator_data(request, coordinator, data_type):
   """Returns {'status' 0/1, data:html or url}"""
@@ -729,7 +479,7 @@ def create_coordinator_data(request, coordinator, data_type):
   return HttpResponse(json.dumps(response), mimetype="application/json")
 
 
-@check_job_access_permission
+@check_job_access_permission()
 def clone_coordinator(request, coordinator):
   if request.method != 'POST':
     raise PopupException(_('A POST request is required.'))
@@ -741,7 +491,7 @@ def clone_coordinator(request, coordinator):
   return HttpResponse(json.dumps(response), mimetype="application/json")
 
 
-@check_job_access_permission
+@check_job_access_permission()
 def submit_coordinator(request, coordinator):
   ParametersFormSet = formset_factory(ParameterForm, extra=0)
 

File diff ditekan karena terlalu besar
+ 5 - 0
desktop/core/static/ext/js/jquery/plugins/jquery-ui-autocomplete-1.9.1.min.js


File diff ditekan karena terlalu besar
+ 4 - 0
desktop/core/static/ext/js/jquery/plugins/jquery-ui-draggable-droppable-sortable-1.8.23.min.js


+ 20 - 0
desktop/core/static/ext/js/knockout.mapping-2.3.2.js

@@ -0,0 +1,20 @@
+/// Knockout Mapping plugin v2.3.2
+/// (c) 2012 Steven Sanderson, Roy Jacobs - http://knockoutjs.com/
+/// License: MIT (http://www.opensource.org/licenses/mit-license.php)
+(function(d){"function"===typeof require&&"object"===typeof exports&&"object"===typeof module?d(require("knockout"),exports):"function"===typeof define&&define.amd?define(["knockout","exports"],d):d(ko,ko.mapping={})})(function(d,e){function w(b,c){for(var a in c)if(c.hasOwnProperty(a)&&c[a])if(a&&b[a]&&"array"!==e.getType(b[a]))w(b[a],c[a]);else if("array"===e.getType(b[a])&&"array"===e.getType(c[a])){for(var d=b,g=a,i=b[a],r=c[a],t={},h=i.length-1;0<=h;--h)t[i[h]]=i[h];for(h=r.length-1;0<=h;--h)t[r[h]]=
+r[h];i=[];r=void 0;for(r in t)i.push(t[r]);d[g]=i}else b[a]=c[a]}function D(b,c){var a={};w(a,b);w(a,c);return a}function A(b,c){for(var a=D({},b),d=K.length-1;0<=d;d--){var e=K[d];a[e]&&(a[""]instanceof Object||(a[""]={}),a[""][e]=a[e],delete a[e])}c&&(a.ignore=p(c.ignore,a.ignore),a.include=p(c.include,a.include),a.copy=p(c.copy,a.copy));a.ignore=p(a.ignore,j.ignore);a.include=p(a.include,j.include);a.copy=p(a.copy,j.copy);a.mappedProperties=a.mappedProperties||{};return a}function p(b,c){"array"!==
+e.getType(b)&&(b="undefined"===e.getType(b)?[]:[b]);"array"!==e.getType(c)&&(c="undefined"===e.getType(c)?[]:[c]);return d.utils.arrayGetDistinctValues(b.concat(c))}function E(b,c,a,f,g,i,r){var t="array"===e.getType(d.utils.unwrapObservable(c)),i=i||"";if(e.isMapped(b))var h=d.utils.unwrapObservable(b)[q],a=D(h,a);var m=function(){return a[f]&&a[f].create instanceof Function},j=function(b){var e=F,h=d.dependentObservable;d.dependentObservable=function(a,b,c){c=c||{};a&&"object"==typeof a&&(c=a);
+var f=c.deferEvaluation,L=!1;c.deferEvaluation=!0;a=new G(a,b,c);if(!f){var g=a,f=d.dependentObservable;d.dependentObservable=G;a=d.isWriteableObservable(g);d.dependentObservable=f;a=G({read:function(){L||(d.utils.arrayRemoveItem(e,g),L=!0);return g.apply(g,arguments)},write:a&&function(a){return g(a)},deferEvaluation:!0});e.push(a)}return a};d.dependentObservable.fn=G.fn;d.computed=d.dependentObservable;b=d.utils.unwrapObservable(g)instanceof Array?a[f].create({data:b||c,parent:r,skip:M}):a[f].create({data:b||
+c,parent:r});d.dependentObservable=h;d.computed=d.dependentObservable;return b},x=function(){return a[f]&&a[f].update instanceof Function},y=function(b,e){var g={data:e||c,parent:r,target:d.utils.unwrapObservable(b)};d.isWriteableObservable(b)&&(g.observable=b);return a[f].update(g)};if(h=H.get(c))return h;f=f||"";if(t){var t=[],s=!1,k=function(a){return a};a[f]&&a[f].key&&(k=a[f].key,s=!0);d.isObservable(b)||(b=d.observableArray([]),b.mappedRemove=function(a){var c=typeof a=="function"?a:function(b){return b===
+k(a)};return b.remove(function(a){return c(k(a))})},b.mappedRemoveAll=function(a){var c=B(a,k);return b.remove(function(a){return d.utils.arrayIndexOf(c,k(a))!=-1})},b.mappedDestroy=function(a){var c=typeof a=="function"?a:function(b){return b===k(a)};return b.destroy(function(a){return c(k(a))})},b.mappedDestroyAll=function(a){var c=B(a,k);return b.destroy(function(a){return d.utils.arrayIndexOf(c,k(a))!=-1})},b.mappedIndexOf=function(a){var c=B(b(),k),a=k(a);return d.utils.arrayIndexOf(c,a)},b.mappedCreate=
+function(a){if(b.mappedIndexOf(a)!==-1)throw Error("There already is an object with the key that you specified.");var c=m()?j(a):a;if(x()){a=y(c,a);d.isWriteableObservable(c)?c(a):c=a}b.push(c);return c});var h=B(d.utils.unwrapObservable(b),k).sort(),n=B(c,k);s&&n.sort();var s=d.utils.compareArrays(h,n),h={},p,z=d.utils.unwrapObservable(c),v={},w=!0,n=0;for(p=z.length;n<p;n++){var o=k(z[n]);if(void 0===o||o instanceof Object){w=!1;break}v[o]=z[n]}var z=[],A=0,n=0;for(p=s.length;n<p;n++){var o=s[n],
+l,u=i+"["+n+"]";switch(o.status){case "added":var C=w?v[o.value]:I(d.utils.unwrapObservable(c),o.value,k);l=E(void 0,C,a,f,b,u,g);m()||(l=d.utils.unwrapObservable(l));u=N(d.utils.unwrapObservable(c),C,h);l===M?A++:z[u-A]=l;h[u]=!0;break;case "retained":C=w?v[o.value]:I(d.utils.unwrapObservable(c),o.value,k);l=I(b,o.value,k);E(l,C,a,f,b,u,g);u=N(d.utils.unwrapObservable(c),C,h);z[u]=l;h[u]=!0;break;case "deleted":l=I(b,o.value,k)}t.push({event:o.status,item:l})}b(z);a[f]&&a[f].arrayChanged&&d.utils.arrayForEach(t,
+function(b){a[f].arrayChanged(b.event,b.item)})}else if(O(c)){b=d.utils.unwrapObservable(b);if(!b){if(m())return s=j(),x()&&(s=y(s)),s;if(x())return y(s);b={}}x()&&(b=y(b));H.save(c,b);if(x())return b;P(c,function(f){var e=i.length?i+"."+f:f;if(-1==d.utils.arrayIndexOf(a.ignore,e))if(-1!=d.utils.arrayIndexOf(a.copy,e))b[f]=c[f];else{var g=H.get(c[f]),h=E(b[f],c[f],a,f,b,e,b),g=g||h;if(d.isWriteableObservable(b[f]))b[f](d.utils.unwrapObservable(g));else b[f]=g;a.mappedProperties[e]=!0}})}else switch(e.getType(c)){case "function":x()?
+d.isWriteableObservable(c)?(c(y(c)),b=c):b=y(c):b=c;break;default:if(d.isWriteableObservable(b))return l=x()?y(b):d.utils.unwrapObservable(c),b(l),l;b=m()?j():d.observable(d.utils.unwrapObservable(c))}return b}function N(b,c,a){for(var d=0,e=b.length;d<e;d++)if(!0!==a[d]&&b[d]===c)return d;return null}function Q(b,c){var a;c&&(a=c(b));"undefined"===e.getType(a)&&(a=b);return d.utils.unwrapObservable(a)}function I(b,c,a){for(var b=d.utils.unwrapObservable(b),f=0,e=b.length;f<e;f++){var i=b[f];if(Q(i,
+a)===c)return i}throw Error("When calling ko.update*, the key '"+c+"' was not found!");}function B(b,c){return d.utils.arrayMap(d.utils.unwrapObservable(b),function(a){return c?Q(a,c):a})}function P(b,c){if("array"===e.getType(b))for(var a=0;a<b.length;a++)c(a);else for(a in b)c(a)}function O(b){var c=e.getType(b);return("object"===c||"array"===c)&&null!==b}function S(){var b=[],c=[];this.save=function(a,f){var e=d.utils.arrayIndexOf(b,a);0<=e?c[e]=f:(b.push(a),c.push(f))};this.get=function(a){a=
+d.utils.arrayIndexOf(b,a);return 0<=a?c[a]:void 0}}function R(){var b={},c=function(a){var c;try{c=a}catch(d){c="$$$"}a=b[c];void 0===a&&(a=new S,b[c]=a);return a};this.save=function(a,b){c(a).save(a,b)};this.get=function(a){return c(a).get(a)}}var q="__ko_mapping__",G=d.dependentObservable,J=0,F,H,K=["create","update","key","arrayChanged"],M={},v={include:["_destroy"],ignore:[],copy:[]},j=v;e.isMapped=function(b){return(b=d.utils.unwrapObservable(b))&&b[q]};e.fromJS=function(b){if(0==arguments.length)throw Error("When calling ko.fromJS, pass the object you want to convert.");
+window.setTimeout(function(){J=0},0);J++||(F=[],H=new R);var c,a;2==arguments.length&&(arguments[1][q]?a=arguments[1]:c=arguments[1]);3==arguments.length&&(c=arguments[1],a=arguments[2]);a&&(c=D(c,a[q]));c=A(c);var d=E(a,b,c);a&&(d=a);--J||window.setTimeout(function(){for(;F.length;){var a=F.pop();a&&a()}},0);d[q]=D(d[q],c);return d};e.fromJSON=function(b){var c=d.utils.parseJson(b);arguments[0]=c;return e.fromJS.apply(this,arguments)};e.updateFromJS=function(){throw Error("ko.mapping.updateFromJS, use ko.mapping.fromJS instead. Please note that the order of parameters is different!");
+};e.updateFromJSON=function(){throw Error("ko.mapping.updateFromJSON, use ko.mapping.fromJSON instead. Please note that the order of parameters is different!");};e.toJS=function(b,c){j||e.resetDefaultOptions();if(0==arguments.length)throw Error("When calling ko.mapping.toJS, pass the object you want to convert.");if("array"!==e.getType(j.ignore))throw Error("ko.mapping.defaultOptions().ignore should be an array.");if("array"!==e.getType(j.include))throw Error("ko.mapping.defaultOptions().include should be an array.");
+if("array"!==e.getType(j.copy))throw Error("ko.mapping.defaultOptions().copy should be an array.");c=A(c,b[q]);return e.visitModel(b,function(a){return d.utils.unwrapObservable(a)},c)};e.toJSON=function(b,c){var a=e.toJS(b,c);return d.utils.stringifyJson(a)};e.defaultOptions=function(){if(0<arguments.length)j=arguments[0];else return j};e.resetDefaultOptions=function(){j={include:v.include.slice(0),ignore:v.ignore.slice(0),copy:v.copy.slice(0)}};e.getType=function(b){if(b&&"object"===typeof b){if(b.constructor==
+(new Date).constructor)return"date";if("[object Array]"===Object.prototype.toString.call(b))return"array"}return typeof b};e.visitModel=function(b,c,a){a=a||{};a.visitedObjects=a.visitedObjects||new R;var f,g=d.utils.unwrapObservable(b);if(O(g))a=A(a,g[q]),c(b,a.parentName),f="array"===e.getType(g)?[]:{};else return c(b,a.parentName);a.visitedObjects.save(b,f);var i=a.parentName;P(g,function(b){if(!(a.ignore&&-1!=d.utils.arrayIndexOf(a.ignore,b))){var j=g[b],h=a,m=i||"";"array"===e.getType(g)?i&&
+(m+="["+b+"]"):(i&&(m+="."),m+=b);h.parentName=m;if(!(-1===d.utils.arrayIndexOf(a.copy,b)&&-1===d.utils.arrayIndexOf(a.include,b)&&g[q]&&g[q].mappedProperties&&!g[q].mappedProperties[b]&&"array"!==e.getType(g)))switch(e.getType(d.utils.unwrapObservable(j))){case "object":case "array":case "undefined":h=a.visitedObjects.get(j);f[b]="undefined"!==e.getType(h)?h:e.visitModel(j,c,a);break;default:f[b]=c(j,a.parentName)}}});return f}});

+ 1 - 2
desktop/libs/hadoop/src/hadoop/fs/webhdfs.py

@@ -451,11 +451,10 @@ class WebHdfs(Hdfs):
     for stat in self.listdir_stats(source):
       source_file = stat.path
       destination_file = posixpath.join(destination, stat.name)
-      print source_file, destination_file
       if stat.isDir:
         self.copy_remote_dir(source_file, destination_file, dir_mode, owner)
       else:
-        self.copyfile(source_file, destination_file)
+        self.do_as_user(owner, self.copyfile, source_file, destination_file)
         self.do_as_superuser(self.chown, destination_file, owner, owner)
 
 

+ 1 - 0
desktop/libs/liboozie/src/liboozie/oozie_api.py

@@ -153,6 +153,7 @@ class OozieApi(object):
     """
     params = self._get_params()
     resp = self._root.get('job/%s' % (jobid,), params)
+    print resp
     wf = Workflow(self, resp)
     return wf
 

+ 1 - 0
desktop/libs/liboozie/src/liboozie/submittion.py

@@ -86,6 +86,7 @@ class Submission(object):
       raise PopupException(message=msg, detail=str(ex))
 
     oozie_xml = self.job.to_xml()
+    print oozie_xml
     self._do_as(self.user.username , self._copy_files, deployment_dir, oozie_xml)
 
     return deployment_dir

+ 1 - 1
desktop/libs/liboozie/src/liboozie/types.py

@@ -84,7 +84,7 @@ class ControlFlowAction(Action):
 
   @classmethod
   def is_control_flow(self, action_type):
-    return action_type is not None and ':' in action_type
+    return action_type is not None and ( ':' in action_type or action_type == 'switch' )
 
   def _fixup(self):
     """

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini