From 9150e18ec8483f4f7e9e3eafbde3116ed34b709e Mon Sep 17 00:00:00 2001
From: Jared Hancock <jared@osticket.com>
Date: Tue, 30 Sep 2014 22:51:04 -0500
Subject: [PATCH] =?UTF-8?q?filter:=20Filter=202.0=20=E2=80=94=20greater=20?=
 =?UTF-8?q?extensibility?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

This patch rebases filters into a row-based layout and redesigns the filter
apply method to be more extensible. It also redesigns the UI to be more
dynamic and to allow for actions to be added without database modification
and actions can also have complex configurations.
---
 bootstrap.php                                 |   1 +
 include/ajax.filter.php                       |  15 +
 include/class.filter.php                      |  82 ++--
 include/class.filter_action.php               | 395 ++++++++++++++++++
 include/staff/filter.inc.php                  | 238 +++--------
 .../templates/dynamic-form-simple.tmpl.php    |  33 ++
 .../core/b26f29a6-00000000.cleanup.sql        |  11 +
 .../streams/core/b26f29a6-00000000.patch.sql  |  86 ++++
 scp/ajax.php                                  |   3 +
 setup/inc/streams/core/install-mysql.sql      |  12 +
 10 files changed, 650 insertions(+), 226 deletions(-)
 create mode 100644 include/ajax.filter.php
 create mode 100644 include/class.filter_action.php
 create mode 100644 include/staff/templates/dynamic-form-simple.tmpl.php
 create mode 100644 include/upgrader/streams/core/b26f29a6-00000000.cleanup.sql
 create mode 100644 include/upgrader/streams/core/b26f29a6-00000000.patch.sql

diff --git a/bootstrap.php b/bootstrap.php
index 63d71fe87..2085a4fbc 100644
--- a/bootstrap.php
+++ b/bootstrap.php
@@ -125,6 +125,7 @@ class Bootstrap {
 
         define('FILTER_TABLE', $prefix.'filter');
         define('FILTER_RULE_TABLE', $prefix.'filter_rule');
+        define('FILTER_ACTION_TABLE', $prefix.'filter_action');
 
         define('PLUGIN_TABLE', $prefix.'plugin');
         define('SEQUENCE_TABLE', $prefix.'sequence');
diff --git a/include/ajax.filter.php b/include/ajax.filter.php
new file mode 100644
index 000000000..7c34b5442
--- /dev/null
+++ b/include/ajax.filter.php
@@ -0,0 +1,15 @@
+<?php
+
+require_once(INCLUDE_DIR . 'class.filter.php');
+
+class FilterAjaxAPI extends AjaxController {
+
+    function getFilterActionForm($type) {
+        if (!($A = FilterAction::lookupByType($type)))
+            Http::response(404, 'No such filter action type');
+
+        $form = $A->getConfigurationForm();
+        include STAFFINC_DIR . 'templates/dynamic-form-simple.tmpl.php';
+    }
+
+}
diff --git a/include/class.filter.php b/include/class.filter.php
index acc3a3590..c29df8dc5 100644
--- a/include/class.filter.php
+++ b/include/class.filter.php
@@ -14,6 +14,8 @@
     vim: expandtab sw=4 ts=4 sts=4:
 **********************************************************************/
 
+require_once INCLUDE_DIR . 'class.filter_action.php';
+
 class Filter {
 
     var $id;
@@ -293,47 +295,23 @@ class Filter {
 
         return $match;
     }
+
+    function getActions() {
+        return FilterAction::objects()->filter(array(
+            'filter_id'=>$this->getId()
+        ));
+    }
     /**
      * If the matches() method returns TRUE, send the initial ticket to this
      * method to apply the filter actions defined
      */
     function apply(&$ticket, $info=null) {
-        # TODO: Disable alerting
-        # XXX: Does this imply turning it on as well? (via ->sendAlerts())
-        if ($this->disableAlerts()) $ticket['autorespond']=false;
-        #       Set owning department (?)
-        if ($this->getDeptId())     $ticket['deptId']=$this->getDeptId();
-        #       Set ticket priority (?)
-        if ($this->getPriorityId()) $ticket['priorityId']=$this->getPriorityId();
-        #       Set SLA plan (?)
-        if ($this->getSLAId())      $ticket['slaId']=$this->getSLAId();
-        #       Set status
-        if ($this->getStatusId())   $ticket['statusId']=$this->getStatusId();
-        #       Auto-assign to (?)
-        #       XXX: Unset the other (of staffId or teamId) (?)
-        if ($this->getStaffId())    $ticket['staffId']=$this->getStaffId();
-        elseif ($this->getTeamId()) $ticket['teamId']=$this->getTeamId();
-        #       Override name with reply-to information from the TicketFilter
-        #       match
-        if ($this->useReplyToEmail() && $info['reply-to']) {
-            $changed = $info['reply-to'] != $ticket['email']
-                || ($info['reply-to-name'] && $ticket['name'] != $info['reply-to-name']);
-            $ticket['email'] = $info['reply-to'];
-            if ($info['reply-to-name'])
-                $ticket['name'] = $info['reply-to-name'];
-            if ($changed)
-                throw new FilterDataChanged();
+        foreach ($this->getActions() as $a) {
+            $a->apply($ticket, $info);
         }
-
-        # Use canned response.
-        if ($this->getCannedResponse())
-            $ticket['cannedResponseId'] = $this->getCannedResponse();
-
-        # Apply help topic
-        if ($this->getHelpTopic())
-            $ticket['topicId'] = $this->getHelpTopic();
     }
-     static function getSupportedMatches() {
+
+    static function getSupportedMatches() {
         foreach (static::$match_types as $k=>&$v) {
             if (is_callable($v[0]))
                 $v[0] = $v[0]();
@@ -518,17 +496,9 @@ class Filter {
             .',name='.db_input($vars['name'])
             .',execorder='.db_input($vars['execorder'])
             .',email_id='.db_input($emailId)
-            .',dept_id='.db_input($vars['dept_id'])
-            .',status_id='.db_input($vars['status_id'])
-            .',priority_id='.db_input($vars['priority_id'])
-            .',sla_id='.db_input($vars['sla_id'])
-            .',topic_id='.db_input($vars['topic_id'])
             .',match_all_rules='.db_input($vars['match_all_rules'])
             .',stop_onmatch='.db_input(isset($vars['stop_onmatch'])?1:0)
             .',reject_ticket='.db_input(isset($vars['reject_ticket'])?1:0)
-            .',use_replyto_email='.db_input(isset($vars['use_replyto_email'])?1:0)
-            .',disable_autoresponder='.db_input(isset($vars['disable_autoresponder'])?1:0)
-            .',canned_response_id='.db_input($vars['canned_response_id'])
             .',notes='.db_input(Format::sanitize($vars['notes']));
 
 
@@ -558,9 +528,37 @@ class Filter {
         # Don't care about errors stashed in $xerrors
         $xerrors = array();
         self::save_rules($id,$vars,$xerrors);
+        self::save_actions($id, $vars, $errors);
 
         return true;
     }
+
+    function save_actions($id, $vars, &$errors) {
+        if (!is_array(@$vars['actions']))
+            return;
+
+        foreach ($vars['actions'] as $v) {
+            $info = substr($v, 1);
+            switch ($v[0]) {
+            case 'N': # new filter action
+                $I = FilterAction::create(array(
+                    'type'=>$info,
+                    'filter_id'=>$id,
+                ));
+                $I->setConfiguration($errors);
+                $I->save();
+                break;
+            case 'I': # exiting filter action
+                if ($I = FilterAction::lookup($info))
+                    $I->setConfiguration() && $I->save();
+                break;
+            case 'D': # deleted filter action
+                if ($I = FilterAction::lookup($info))
+                    $I->delete();
+                break;
+            }
+        }
+    }
 }
 
 class FilterRule {
diff --git a/include/class.filter_action.php b/include/class.filter_action.php
new file mode 100644
index 000000000..b54eabb68
--- /dev/null
+++ b/include/class.filter_action.php
@@ -0,0 +1,395 @@
+<?php
+
+require_once INCLUDE_DIR . 'class.orm.php';
+
+class FilterAction extends VerySimpleModel {
+    static $meta = array(
+        'table' => FILTER_ACTION_TABLE,
+        'pk' => array('id'),
+        'ordering' => array('sort'),
+    );
+
+    static $registry = array();
+
+    var $_impl;
+    var $_config;
+
+    function getId() {
+        return $this->id;
+    }
+
+    function getConfiguration() {
+        if (!$this->_config) {
+            $this->_config = $this->get('configuration');
+            if (is_string($this->_config))
+                $this->_config = JsonDataParser::parse($this->_config);
+            elseif (!$this->_config)
+                $this->_config = array();
+            foreach ($this->getImpl()->getConfigurationOptions() as $name=>$field)
+                if (!isset($this->_config[$name]))
+                    $this->_config[$name] = $field->get('default');
+        }
+        return $this->_config;
+    }
+
+    function setConfiguration($source=false, &$errors=array()) {
+        $config = array();
+        foreach ($this->getImpl()->getConfigurationForm($source ?: $_POST)
+                ->getFields() as $name=>$field) {
+            $config[$name] = $field->to_php($field->getClean());
+            $errors = array_merge($errors, $field->errors());
+        }
+        if (count($errors) === 0)
+            $this->set('configuration', JsonDataEncoder::encode($config));
+        return count($errors) === 0;
+    }
+
+    function getImpl() {
+        if (!isset($this->_impl)) {
+            if (!($I = self::lookupByType($this->type, $this)))
+                throw new Exception(sprintf(
+                    '%s: No such filter action registered', $this->type));
+            $this->_impl = $I;
+        }
+        return $this->_impl;
+    }
+
+    function apply(&$ticket, array $info) {
+        return $this->getImpl()->apply($ticket, $info);
+    }
+
+    function save($refetch=false) {
+        if ($this->dirty)
+            $this->updated = SqlFunction::NOW();
+        return parent::save($refetch || $this->dirty);
+    }
+
+    static function register($class, $type=false) {
+        // TODO: Check if $class implements TriggerAction
+        self::$registry[$type ?: $class::$type] = $class;
+    }
+
+    static function lookupByType($type, $thisObj=false) {
+        if (!isset(self::$registry[$type]))
+            return null;
+
+        $class = self::$registry[$type];
+        return new $class($thisObj);
+    }
+
+    static function allRegistered() {
+        $types = array();
+        foreach (self::$registry as $type=>$class) {
+            $types[$type] = $class::getName();
+        }
+        return $types;
+    }
+}
+
+abstract class TriggerAction {
+    function __construct($action=false) {
+        $this->action = $action;
+    }
+
+    function getConfiguration() {
+        if ($this->action)
+            return $this->action->getConfiguration();
+        return array();
+    }
+
+    function getConfigurationForm($source=false) {
+        if (!$this->_cform) {
+            $config = $this->getConfiguration();
+            $options = $this->getConfigurationOptions();
+            // Find a uid offset for this guy
+            $uid = 1000;
+            foreach (FilterAction::$registry as $type=>$class) {
+                $uid += 100;
+                if ($type == $this->getType())
+                    break;
+            }
+            // Ensure IDs are unique
+            foreach ($options as $f) {
+                $f->set('id', $uid++);
+            }
+            $this->_cform = new Form($options, $source);
+            if (!$source) {
+                foreach ($this->_cform->getFields() as $name=>$f) {
+                    if ($config && isset($config[$name]))
+                        $f->value = $config[$name];
+                    elseif ($f->get('default'))
+                        $f->value = $f->get('default');
+                }
+            }
+        }
+        return $this->_cform;
+    }
+
+    static function getType() { return static::$type; }
+    static function getName() { return __(static::$name); }
+
+    abstract function apply(&$ticket, array $info);
+    abstract function getConfigurationOptions();
+}
+
+class FA_UseReplyTo extends TriggerAction {
+    static $type = 'replyto';
+    static $name = /* trans */ 'Reply-To Email';
+
+    function apply(&$ticket, array $info) {
+        $config = $this->getConfiguration();
+        if ($config['enable'] && $info['reply-to']) {
+            $ticket['email'] = $info['reply-to'];
+            if ($info['reply-to-name'])
+                $ticket['name'] = $info['reply-to-name'];
+        }
+    }
+
+    function getConfigurationOptions() {
+        return array(
+            'enable' => new BooleanField(array(
+                'configuration' => array(
+                    'desc' => __('Use the Reply-To email header')
+                )
+            )),
+        );
+    }
+}
+FilterAction::register('FA_UseReplyTo');
+
+class FA_DisableAutoResponse extends TriggerAction {
+    static $type = 'noresp';
+    static $name = /* trans */ "Ticket auto-response";
+
+    function apply(&$ticket, array $info) {
+        # TODO: Disable alerting
+        # XXX: Does this imply turning it on as well? (via ->sendAlerts())
+        $config = $this->getConfiguration();
+        if ($config['enable']) {
+            $ticket['autorespond']=false;
+        }
+    }
+
+    function getConfigurationOptions() {
+        return array(
+            'enable' => new BooleanField(array(
+                'configuration' => array(
+                    'desc' => __('<strong>Disable</strong> auto-response')
+                ),
+            )),
+        );
+    }
+}
+FilterAction::register('FA_DisableAutoResponse');
+
+class FA_AutoCannedResponse extends TriggerAction {
+    static $type = 'canned';
+    static $name = /* trans */ "Canned Response";
+
+    function apply(&$ticket, array $info) {
+        $config = $this->getConfiguration();
+        if ($config['canned_id']) {
+            $ticket['cannedResponseId'] = $config['canned_id'];
+        }
+    }
+
+    function getConfigurationOptions() {
+        $sql='SELECT canned_id, title, isenabled FROM '.CANNED_TABLE .' ORDER by title';
+        $choices = array(false => '— '.__('None').' —');
+        if ($res=db_query($sql)) {
+            while (list($id, $title, $isenabled)=db_fetch_row($res)) {
+                if (!$isenabled)
+                    $title .= ' ' . __('(disabled)');
+                $choices[$id] = $title;
+            }
+        }
+        return array(
+            'canned_id' => new ChoiceField(array(
+                'default' => false,
+                'choices' => $choices,
+            )),
+        );
+    }
+}
+FilterAction::register('FA_AutoCannedResponse');
+
+class FA_RouteDepartment extends TriggerAction {
+    static $type = 'dept';
+    static $name = /* trans */ 'Department';
+
+    function apply(&$ticket, array $info) {
+        $config = $this->getConfiguration();
+        if ($config['dept_id'])
+            $ticket['deptId'] = $config['dept_id'];
+    }
+
+    function getConfigurationOptions() {
+        $sql='SELECT dept_id,dept_name FROM '.DEPT_TABLE.' dept ORDER by dept_name';
+        $choices = array();
+        if(($res=db_query($sql)) && db_num_rows($res)){
+            while(list($id,$name)=db_fetch_row($res)){
+                $choices[$id] = $name;
+            }
+        }
+        return array(
+            'dept_id' => new ChoiceField(array(
+                'configuration' => array('prompt' => __('Unchanged')),
+                'choices' => $choices,
+            )),
+        );
+    }
+}
+FilterAction::register('FA_RouteDepartment');
+
+class FA_AssignPriority extends TriggerAction {
+    static $type = 'pri';
+    static $name = /* trans */ "Priority";
+
+    function apply(&$ticket, array $info) {
+        $config = $this->getConfiguration();
+        if ($config['priority'])
+            $ticket['priority_id'] = $config['priority']->getId();
+    }
+
+    function getConfigurationOptions() {
+        $sql = 'SELECT priority_id, priority_desc FROM '.PRIORITY_TABLE
+              .' ORDER BY priority_urgency DESC';
+        $choices = array();
+        if ($res = db_query($sql)) {
+            while ($row = db_fetch_row($res))
+                $choices[$row[0]] = $row[1];
+        }
+        return array(
+            'priority' => new ChoiceField(array(
+                'configuration' => array('prompt' => __('Unchanged')),
+                'choices' => $choices,
+            )),
+        );
+    }
+}
+FilterAction::register('FA_AssignPriority');
+
+class FA_AssignSLA extends TriggerAction {
+    static $type = 'sla';
+    static $name = /* trans */ 'SLA Plan';
+
+    function apply(&$ticket, array $info) {
+        $config = $this->getConfiguration();
+        if ($config['sla_id'])
+            $ticket['slaId'] = $config['sla_id'];
+    }
+
+    function getConfigurationOptions() {
+        $choices = SLA::getSLAs();
+        return array(
+            'sla_id' => new ChoiceField(array(
+                'configuration' => array('prompt' => __('Unchanged')),
+                'choices' => $choices,
+            )),
+        );
+    }
+}
+FilterAction::register('FA_AssignSLA');
+
+class FA_AssignTeam extends TriggerAction {
+    static $type = 'team';
+    static $name = /* trans */ 'Assign Team';
+
+    function apply(&$ticket, array $info) {
+        $config = $this->getConfiguration();
+        if ($config['team_id'])
+            $ticket['teamId'] = $config['team_id'];
+    }
+
+    function getConfigurationOptions() {
+        $sql='SELECT team_id, isenabled, name FROM '.TEAM_TABLE .' ORDER BY name';
+        $choices = array();
+        if(($res=db_query($sql)) && db_num_rows($res)){
+            while (list($id, $isenabled, $name) = db_fetch_row($res)){
+                if (!$isenabled)
+                    $name .= ' '.__('(disabled)');
+                $choices[$id] = $name;
+            }
+        }
+        return array(
+            'team_id' => new ChoiceField(array(
+                'configuration' => array('prompt' => __('Unchanged')),
+                'choices' => $choices,
+            )),
+        );
+    }
+}
+FilterAction::register('FA_AssignTeam');
+
+class FA_AssignAgent extends TriggerAction {
+    static $type = 'agent';
+    static $name = /* trans */ 'Assign Agent';
+
+    function apply(&$ticket, array $info) {
+        $config = $this->getConfiguration();
+        if ($config['staff_id'])
+            $ticket['staffId'] = $config['staff_id'];
+    }
+
+    function getConfigurationOptions() {
+        $choices = Staff::getStaffMembers();
+        return array(
+            'staff_id' => new ChoiceField(array(
+                'configuration' => array('prompt' => __('Unchanged')),
+                'choices' => $choices,
+            )),
+        );
+    }
+}
+FilterAction::register('FA_AssignAgent');
+
+class FA_AssignTopic extends TriggerAction {
+    static $type = 'topic';
+    static $name = /* trans */ 'Help Topic';
+
+    function apply(&$ticket, array $info) {
+        $config = $this->getConfiguration();
+        if ($config['topic_id'])
+            $ticket['topicId'] = $config['topic_id'];
+    }
+
+    function getConfigurationOptions() {
+        $choices = HelpTopic::getAllHelpTopics();
+        return array(
+            'topic_id' => new ChoiceField(array(
+                'configuration' => array('prompt' => __('Unchanged')),
+                'choices' => $choices,
+            )),
+        );
+    }
+}
+FilterAction::register('FA_AssignTopic');
+
+class FA_SetStatus extends TriggerAction {
+    static $type = 'status';
+    static $name = /* trans */ 'Ticket Status';
+
+    function apply(&$ticket, array $info) {
+        $config = $this->getConfiguration();
+        if ($config['status_id'])
+            $ticket['statusId'] = $config['status_id'];
+    }
+
+    function getConfigurationOptions() {
+        $choices = array();
+        foreach (TicketStatusList::getStatuses() as $S) {
+            // TODO: Move this to TicketStatus::getName
+            $name = $S->getName();
+            if (!($isenabled = $S->isEnabled()))
+                $name.=' '.__('(disabled)');
+            $choices[$S->getId()] = $name;
+        }
+        return array(
+            'status_id' => new ChoiceField(array(
+                'configuration' => array('prompt' => __('Unchanged')),
+                'choices' => $choices,
+            )),
+        );
+    }
+}
+FilterAction::register('FA_SetStatus');
diff --git a/include/staff/filter.inc.php b/include/staff/filter.inc.php
index f09731af6..5ef7d36a1 100644
--- a/include/staff/filter.inc.php
+++ b/include/staff/filter.inc.php
@@ -181,195 +181,65 @@ $info=Format::htmlchars(($errors && $_POST)?$_POST:$info);
                     &nbsp;<i class="help-tip icon-question-sign" href="#reject_ticket"></i>
             </td>
         </tr>
-        <tr>
-            <td width="180">
-                <?php echo __('Reply-To Email');?>:
-            </td>
-            <td>
-                <input type="checkbox" name="use_replyto_email" value="1" <?php echo $info['use_replyto_email']?'checked="checked"':''; ?> >
-                    <?php echo __('<strong>Use</strong> Reply-To Email');?> <em>(<?php echo __('if available');?>)</em>
-                    &nbsp;<i class="help-tip icon-question-sign" href="#reply_to_email"></i></em>
-            </td>
-        </tr>
-        <tr>
-            <td width="180">
-                <?php echo __('Ticket auto-response');?>:
-            </td>
-            <td>
-                <input type="checkbox" name="disable_autoresponder" value="1" <?php echo $info['disable_autoresponder']?'checked="checked"':''; ?> >
-                    <?php echo __('<strong>Disable</strong> auto-response.');?>
-                    &nbsp;<i class="help-tip icon-question-sign" href="#ticket_auto_response"></i>
-            </td>
-        </tr>
-        <tr>
-            <td width="180">
-                <?php echo __('Canned Response');?>:
-            </td>
-                <td>
-                <select name="canned_response_id">
-                    <option value="">&mdash; <?php echo __('None');?> &mdash;</option>
-                    <?php
-                    $sql='SELECT canned_id, title, isenabled FROM '.CANNED_TABLE .' ORDER by title';
-                    if ($res=db_query($sql)) {
-                        while (list($id, $title, $isenabled)=db_fetch_row($res)) {
-                            $selected=($info['canned_response_id'] &&
-                                    $id==$info['canned_response_id'])
-                                ? 'selected="selected"' : '';
-
-                            if (!$isenabled)
-                                $title .= ' ' . __('(disabled)');
-
-                            echo sprintf('<option value="%d" %s>%s</option>',
-                                $id, $selected, $title);
-                        }
-                    }
-                    ?>
-                </select>
-                &nbsp;<i class="help-tip icon-question-sign" href="#canned_response"></i>
-            </td>
-        </tr>
-        <tr>
-            <td width="180">
-                <?php echo __('Department');?>:
-            </td>
-            <td>
-                <select name="dept_id">
-                    <option value="">&mdash; <?php echo __('Default');?> &mdash;</option>
-                    <?php
-                    foreach (Dept::getDepartments() as $id=>$name) {
-                        $selected=($info['dept_id'] && $id==$info['dept_id'])?'selected="selected"':'';
-                        echo sprintf('<option value="%d" %s>%s</option>',$id,$selected,$name);
-                    }
-                    ?>
-                </select>
-                &nbsp;<span class="error">*&nbsp;<?php echo $errors['dept_id']; ?></span>&nbsp;<i class="help-tip icon-question-sign" href="#department"></i>
-            </td>
-        </tr>
-        <tr>
-            <td width="180">
-                <?php echo __('Status'); ?>:
-            </td>
-            <td>
-                <span>
-                <select name="status_id">
-                    <option value="">&mdash; <?php echo __('Default'); ?> &mdash;</option>
-                    <?php
-                    foreach (TicketStatusList::getStatuses() as $status) {
-                        $name = $status->getName();
-                        if (!($isenabled = $status->isEnabled()))
-                            $name.=' '.__('(disabled)');
-
-                        echo sprintf('<option value="%d" %s %s>%s</option>',
-                                $status->getId(),
-                                ($info['status_id'] == $status->getId())
-                                 ? 'selected="selected"' : '',
-                                 $isenabled ? '' : 'disabled="disabled"',
-                                 $name
-                                );
-                    }
-                    ?>
-                </select>
-                &nbsp;
-                <span class="error"><?php echo $errors['status_id']; ?></span>
-                <i class="help-tip icon-question-sign" href="#status"></i>
-                </span>
-            </td>
-        </tr>
-        <tr>
-            <td width="180">
-                <?php echo __('Priority');?>:
-            </td>
-            <td>
-                <select name="priority_id">
-                    <option value="">&mdash; <?php echo __('Default');?> &mdash;</option>
-                    <?php
-                    $sql='SELECT priority_id,priority_desc FROM '.PRIORITY_TABLE.' pri ORDER by priority_urgency DESC';
-                    if(($res=db_query($sql)) && db_num_rows($res)){
-                        while(list($id,$name)=db_fetch_row($res)){
-                            $selected=($info['priority_id'] && $id==$info['priority_id'])?'selected="selected"':'';
-                            echo sprintf('<option value="%d" %s>%s</option>',$id,$selected,$name);
-                        }
-                    }
-                    ?>
-                </select>
-                &nbsp;<span class="error">*&nbsp;<?php echo $errors['priority_id']; ?></span>
-                &nbsp;<i class="help-tip icon-question-sign" href="#priority"></i>
-            </td>
-        </tr>
-        <tr>
-            <td width="180">
-                <?php echo __('SLA Plan');?>:
-            </td>
-            <td>
-                <select name="sla_id">
-                    <option value="0">&mdash; <?php echo __('System Default');?> &mdash;</option>
-                    <?php
-                    if($slas=SLA::getSLAs()) {
-                        foreach($slas as $id =>$name) {
-                            echo sprintf('<option value="%d" %s>%s</option>',
-                                    $id, ($info['sla_id']==$id)?'selected="selected"':'',$name);
-                        }
-                    }
-                    ?>
-                </select>
-                &nbsp;<span class="error">&nbsp;<?php echo $errors['sla_id']; ?></span>
-                &nbsp;<i class="help-tip icon-question-sign" href="#sla_plan"></i>
-            </td>
-        </tr>
-        <tr>
-            <td width="180">
-                <?php echo __('Auto-assign To');?>:
-            </td>
-            <td>
-                <select name="assign">
-                    <option value="0">&mdash; <?php echo __('Unassigned');?> &mdash;</option>
-                    <?php
-                    if (($users=Staff::getStaffMembers())) {
-                        echo '<OPTGROUP label="'.__('Agents').'">';
-                        foreach($users as $id => $name) {
-                            $name = new PersonsName($name);
-                            $k="s$id";
-                            $selected = ($info['assign']==$k || $info['staff_id']==$id)?'selected="selected"':'';
-                            ?>
-                            <option value="<?php echo $k; ?>"<?php echo $selected; ?>><?php echo $name; ?></option>
-                        <?php
-                        }
-                        echo '</OPTGROUP>';
-                    }
-                    $sql='SELECT team_id, isenabled, name FROM '.TEAM_TABLE .' ORDER BY name';
-                    if ($teams = Team::getTeams()) {
-                        echo '<OPTGROUP label="'.__('Teams').'">';
-                        foreach ($teams as $id=>$name) {
-                            $k="t$id";
-                            $selected = ($info['assign']==$k || $info['team_id']==$id)?'selected="selected"':'';
-                            ?>
-                            <option value="<?php echo $k; ?>"<?php echo $selected; ?>><?php echo $name; ?></option>
-                        <?php
-                        }
-                        echo '</OPTGROUP>';
-                    }
-                    ?>
-                </select>
-                &nbsp;<span class="error">&nbsp;<?php echo
-                $errors['assign']; ?></span><i class="help-tip icon-question-sign" href="#auto_assign"></i>
+    </tbody>
+    <tbody id="dynamic-actions">
+<?php
+$existing = array();
+if ($filter) { foreach ($filter->getActions() as $A) {
+    $existing[] = $A->type;
+?>
+        <tr><td><?php echo $A->getImpl()->getName(); ?>:</td>
+            <td><div style="position:relative"><?php
+                $form = $A->getImpl()->getConfigurationForm();
+                include STAFFINC_DIR . 'templates/dynamic-form-simple.tmpl.php';
+?>
+                <input type="hidden" name="actions[]" value="I<?php echo $A->getId(); ?>"/>
+                <div class="pull-right" style="position:absolute;top:2px;right:2px;">
+                    <a href="#" title="<?php echo __('clear'); ?>" onclick="javascript:
+        if (!confirm(__('You sure?')))
+            return false;
+        $(this).closest('td').find('input[name=\'actions[]\']')
+            .val(function(i,v) { return 'D' + v.substring(1); });
+        $(this).closest('tr').fadeOut(400, function() { $(this).hide(); });
+        return false;"><i class="icon-trash"></i></a>
+                </div>
+</div>
             </td>
         </tr>
+<?php } } ?>
+    </tbody>
+    <tbody>
         <tr>
-            <td width="180">
-                <?php echo __('Help Topic'); ?>
-            </td>
+            <td><strong>
+                <?php echo __('Add'); ?>:
+            </strong></td>
             <td>
-                <select name="topic_id">
-                    <option value="0" selected="selected">&mdash; <?php
-                        echo __('Unchanged'); ?> &mdash;</option>
-                    <?php
-                    foreach (Topic::getAllHelpTopics(true) as $id=>$name) {
-                        $selected=($info['topic_id'] && $id==$info['topic_id'])?'selected="selected"':'';
-                        echo sprintf('<option value="%d" %s>%s</option>',$id,$selected,$name);
-                    }
-                    ?>
+                <select name="new-action" id="new-action-select"
+                    onchange="javascript: $('#new-action-btn').trigger('click');">
+                    <option value=""><?php echo __('— Select an Action —'); ?></option>
+<?php foreach (FilterAction::allRegistered() as $type=>$name) {
+    if (in_array($type, $existing))
+        continue;
+?>
+                    <option data-title="<?php echo $name; ?>" value="<?php echo $type; ?>"><?php echo $name; ?></option>
+<?php } ?>
                 </select>
-                &nbsp;<span class="error"><?php echo $errors['topic_id']; ?></span><i class="help-tip icon-question-sign" href="#help_topic"></i>
+                <input id="new-action-btn" type="button" value="<?php echo __('Add'); ?>"
+                onclick="javascript:
+        var selected = $('#new-action-select').find(':selected');
+        $('#dynamic-actions')
+          .append($('<tr></tr>')
+            .append($('<td></td>')
+              .text(selected.data('title'))
+            ).append($('<td></td>')
+              .append($('<em></em>').text(__('Loading ...')))
+              .load('ajax.php/filter/action/' + selected.val() + '/config', function() {
+                selected.prop('disabled', true);
+              })
+            )
+          ).append(
+            $('<input>').attr({type:'hidden',name:'actions[]',value:'N'+selected.val()})
+          );"/>
             </td>
         </tr>
         <tr>
diff --git a/include/staff/templates/dynamic-form-simple.tmpl.php b/include/staff/templates/dynamic-form-simple.tmpl.php
new file mode 100644
index 000000000..896805c45
--- /dev/null
+++ b/include/staff/templates/dynamic-form-simple.tmpl.php
@@ -0,0 +1,33 @@
+        <?php
+        echo $form->getMedia();
+        foreach ($form->getFields() as $name=>$f) { ?>
+            <div class="flush-left custom-field" id="field<?php echo $f->getWidget()->id;
+                ?>" <?php if (!$f->isVisible()) echo 'style="display:none;"'; ?>>
+            <div class="field-label <?php if ($f->get('required')) echo 'required'; ?>">
+            <label for="<?php echo $f->getWidget()->name; ?>">
+      <?php if ($f->get('label')) { ?>
+                <?php echo Format::htmlchars($f->get('label')); ?>:
+      <?php } ?>
+      <?php if ($f->get('required')) { ?>
+                <span class="error">*</span>
+      <?php } ?>
+            </label>
+            <?php
+            if ($f->get('hint')) { ?>
+                <br/><em style="color:gray;display:inline-block"><?php
+                    echo Format::htmlchars($f->get('hint')); ?></em>
+            <?php
+            } ?>
+            </div><div>
+            <?php
+            $f->render();
+            ?>
+            </div>
+            <?php
+            foreach ($f->errors() as $e) { ?>
+                <div class="error"><?php echo $e; ?></div>
+            <?php } ?>
+            </div>
+        <?php }
+        ?>
+    </form>
diff --git a/include/upgrader/streams/core/b26f29a6-00000000.cleanup.sql b/include/upgrader/streams/core/b26f29a6-00000000.cleanup.sql
new file mode 100644
index 000000000..d40218224
--- /dev/null
+++ b/include/upgrader/streams/core/b26f29a6-00000000.cleanup.sql
@@ -0,0 +1,11 @@
+ALTER TABLE `%TABLE_PREFIX%filter`
+  DROP `use_replyto_email`,
+  DROP `disable_autoresponder`,
+  DROP `canned_response_id`,
+  DROP `status_id`,
+  DROP `priority_id`,
+  DROP `dept_id`,
+  DROP `staff_id`,
+  DROP `team_id`,
+  DROP `sla_id`,
+  DROP `form_id`;
diff --git a/include/upgrader/streams/core/b26f29a6-00000000.patch.sql b/include/upgrader/streams/core/b26f29a6-00000000.patch.sql
new file mode 100644
index 000000000..821b2cbbe
--- /dev/null
+++ b/include/upgrader/streams/core/b26f29a6-00000000.patch.sql
@@ -0,0 +1,86 @@
+/**
+ * @version v1.9.5
+ * @signature 00000000000000000000000000000000
+ * @title Add flexible filter actions
+ *
+ * This patch migrates the columnar layout of the %filter table into a new
+ * %filter_action table. The cleanup portion of the script will drop the old
+ * columns from the %filter table.
+ */
+
+DROP TABLE IF EXISTS `%TABLE_PREFIX%filter_action`;
+CREATE TABLE `%TABLE_PREFIX%filter_action` (
+  `id` int(11) unsigned NOT NULL auto_increment,
+  `filter_id` int(10) unsigned NOT NULL,
+  `sort` int(10) unsigned NOT NULL default 0,
+  `type` varchar(24) NOT NULL,
+  `configuration` text,
+  `updated` datetime NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `filter_id` (`filter_id`)
+) DEFAULT CHARSET=utf8;
+
+INSERT INTO `%TABLE_PREFIX%filter_action`
+    (`filter_id`, `type`, `configuration`, `updated`)
+    SELECT `id`, 'replyto', '{"enable":true}', `updated`
+    FROM `%TABLE_PREFIX%filter`
+    WHERE `use_replyto_email` != 0;
+
+INSERT INTO `%TABLE_PREFIX%filter_action`
+    (`filter_id`, `type`, `configuration`, `updated`)
+    SELECT `id`, 'noresp', '{"enable":true}', `updated`
+    FROM `%TABLE_PREFIX%filter`
+    WHERE `disable_autoresponder` != 0;
+
+INSERT INTO `%TABLE_PREFIX%filter_action`
+    (`filter_id`, `type`, `configuration`, `updated`)
+    SELECT `id`, 'canned', CONCAT('{"canned_id":',`canned_response_id`,'}'), `updated`
+    FROM `%TABLE_PREFIX%filter`
+    WHERE `canned_response_id` != 0;
+
+INSERT INTO `%TABLE_PREFIX%filter_action`
+    (`filter_id`, `type`, `configuration`, `updated`)
+    SELECT `id`, 'dept', CONCAT('{"dept_id":',`dept_id`,'}'), `updated`
+    FROM `%TABLE_PREFIX%filter`
+    WHERE `dept_id` != 0;
+
+INSERT INTO `%TABLE_PREFIX%filter_action`
+    (`filter_id`, `type`, `configuration`, `updated`)
+    SELECT `id`, 'pri', CONCAT('{"priority_id":',`priority_id`,'}'), `updated`
+    FROM `%TABLE_PREFIX%filter`
+    WHERE `priority_id` != 0;
+
+INSERT INTO `%TABLE_PREFIX%filter_action`
+    (`filter_id`, `type`, `configuration`, `updated`)
+    SELECT `id`, 'sla', CONCAT('{"sla_id":',`sla_id`,'}'), `updated`
+    FROM `%TABLE_PREFIX%filter`
+    WHERE `sla_id` != 0;
+
+INSERT INTO `%TABLE_PREFIX%filter_action`
+    (`filter_id`, `type`, `configuration`, `updated`)
+    SELECT `id`, 'team', CONCAT('{"team_id":',`team_id`,'}'), `updated`
+    FROM `%TABLE_PREFIX%filter`
+    WHERE `team_id` != 0;
+
+INSERT INTO `%TABLE_PREFIX%filter_action`
+    (`filter_id`, `type`, `configuration`, `updated`)
+    SELECT `id`, 'agent', CONCAT('{"staff_id":',`staff_id`,'}'), `updated`
+    FROM `%TABLE_PREFIX%filter`
+    WHERE `staff_id` != 0;
+
+INSERT INTO `%TABLE_PREFIX%filter_action`
+    (`filter_id`, `type`, `configuration`, `updated`)
+    SELECT `id`, 'topic', CONCAT('{"topic_id":',`topic_id`,'}'), `updated`
+    FROM `%TABLE_PREFIX%filter`
+    WHERE `topic_id` != 0;
+
+INSERT INTO `%TABLE_PREFIX%filter_action`
+    (`filter_id`, `type`, `configuration`, `updated`)
+    SELECT `id`, 'status', CONCAT('{"status_id":',`status_id`,'}'), `updated`
+    FROM `%TABLE_PREFIX%filter`
+    WHERE `status_id` != 0;
+
+-- Set new schema signature
+UPDATE `%TABLE_PREFIX%config`
+    SET `value` = '00000000000000000000000000000000'
+    WHERE `key` = 'schema_signature' AND `namespace` = 'core';
diff --git a/scp/ajax.php b/scp/ajax.php
index 79dd8e1b3..140085b39 100644
--- a/scp/ajax.php
+++ b/scp/ajax.php
@@ -60,6 +60,9 @@ $dispatcher = patterns('',
         url_post('^upload/(\w+)?$', 'attach'),
         url_get('^(?P<id>\d+)/fields/view$', 'getAllFields')
     )),
+    url('^/filter/', patterns('ajax.filter.php:FilterAjaxAPI',
+        url_get('^action/(?P<type>\w+)/config$', 'getFilterActionForm')
+    )),
     url('^/list/', patterns('ajax.forms.php:DynamicFormsAjaxAPI',
         url_get('^(?P<list>\w+)/item/(?P<id>\d+)/properties$', 'getListItemProperties'),
         url_post('^(?P<list>\w+)/item/(?P<id>\d+)/properties$', 'saveListItemProperties')
diff --git a/setup/inc/streams/core/install-mysql.sql b/setup/inc/streams/core/install-mysql.sql
index 738df56ce..669f071e1 100644
--- a/setup/inc/streams/core/install-mysql.sql
+++ b/setup/inc/streams/core/install-mysql.sql
@@ -326,6 +326,18 @@ CREATE TABLE `%TABLE_PREFIX%filter` (
   KEY `email_id` (`email_id`)
 ) DEFAULT CHARSET=utf8;
 
+DROP TABLE IF EXISTS `%TABLE_PREFIX%filter_action`;
+CREATE TABLE `%TABLE_PREFIX%filter_action` (
+  `id` int(11) unsigned NOT NULL auto_increment,
+  `filter_id` int(10) unsigned NOT NULL,
+  `sort` int(10) unsigned NOT NULL default 0,
+  `type` varchar(24) NOT NULL,
+  `configuration` text,
+  `updated` datetime NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `filter_id` (`filter_id`)
+) DEFAULT CHARSET=utf8;
+
 DROP TABLE IF EXISTS `%TABLE_PREFIX%filter_rule`;
 CREATE TABLE `%TABLE_PREFIX%filter_rule` (
   `id` int(11) unsigned NOT NULL auto_increment,
-- 
GitLab