Add support for different selection modes, using a data attribute in the mainDetailGrid

Add code to handle single, multi and no selection. Add the class 'hover' to
table rows, that are selectable to indicate when a row is selectable.

refs #3788
This commit is contained in:
Matthias Jentsch 2013-10-17 19:08:00 +02:00
parent ab2bbcd6dd
commit 4133d0093d
14 changed files with 252 additions and 357 deletions

View File

@ -26,12 +26,14 @@
*/
// {{{ICINGA_LICENSE_HEADER}}}
use \Icinga\Web\Controller\ActionController;
use \Icinga\Web\Form;
use \Icinga\Module\Monitoring\Backend;
use \Icinga\Module\Monitoring\Object\Host;
use \Icinga\Module\Monitoring\Object\Service;
use \Icinga\Module\Monitoring\Form\Command\MultiCommandFlagForm;
use \Icinga\Web\Form;
use \Icinga\Module\Monitoring\DataView\HostAndServiceStatus as HostAndServiceStatusView;
use \Icinga\Module\Monitoring\DataView\Comment as CommentView;
use \Icinga\Web\Controller\ActionController;
/**
* Displays aggregations collections of multiple objects.
@ -41,91 +43,161 @@ class Monitoring_MultiController extends ActionController
public function init()
{
$this->view->queries = $this->getDetailQueries();
$this->view->wildcard = false;
$this->backend = Backend::createBackend($this->_getParam('backend'));
}
public function hostAction()
{
$queries = $this->view->queries;
$hosts = array();
$hostnames = array();
$comments = array();
$downtimes = array();
$errors = array();
foreach ($this->view->queries as $index => $query) {
if (!array_key_exists('host', $query)) {
$errors[] = 'Query ' . $index . ' misses property host.';
continue;
}
$host = Host::fetch($this->backend, $query['host']);
foreach ($host->comments as $comment) {
$comments[$comment->comment_id] = null;
}
if ($host->host_in_downtime) {
$downtimes[] += $host->host_in_downtime;
}
$hostnames[] = $host->host_name;
$hosts[] = $host;
}
if ($this->_getParam('host') === '*') {
// fetch all hosts
$hosts = HostAndServiceStatusView::fromRequest(
$this->_request,
array(
'host_name',
'host_in_downtime',
'host_accepts_passive_checks',
'host_does_active_checks',
'host_notifications_enabled',
// TODO: flags missing in HostAndServiceStatus:
'host_obsessing',
'host_event_handler_enabled',
'host_flap_detection_enabled'
// <<
)
)->getQuery()->fetchAll();
$comments = array_keys($this->getUniqueValues(
CommentView::fromRequest($this->_request)->getQuery()->fetchAll(),
'comment_id'
));
} else {
// fetch specified hosts
foreach ($queries as $index => $query) {
if (!array_key_exists('host', $query)) {
$errors[] = 'Query ' . $index . ' misses property host.';
continue;
}
$hosts[] = Host::fetch($this->backend, $query['host']);
}
}
$this->view->objects = $this->view->hosts = $hosts;
$this->view->comments = array_keys($comments);
$this->view->hostnames = $hostnames;
$this->view->downtimes = $downtimes;
$this->view->comments = isset($comments) ? $comments : $this->getComments($hosts);
$this->view->hostnames = $this->getHostnames($hosts);
$this->view->downtimes = $this->getDowntimes($hosts);
$this->view->errors = $errors;
$this->handleConfigurationForm();
$this->view->form->setAction('/icinga2-web/monitoring/multi/host');
}
/**
* Create an array with all unique values as keys.
*
* @param array $values The array containing the objects
* @param $key The key to access
*
* @return array
*/
private function getUniqueValues(array $values, $key)
{
$unique = array();
foreach ($values as $value)
{
$unique[$value->{$key}] = null;
}
return $unique;
}
private function getComments($objects)
{
$unique = array();
foreach ($objects as $object) {
$unique = array_merge($this->getUniqueValues($object->comments, 'comment_id'));
}
return array_keys($unique);
}
private function getHostnames($objects)
{
$objectnames = array();
foreach ($objects as $object) {
$objectnames[] = $object->host_name;
}
return $objectnames;
}
private function getDowntimes($objects)
{
$downtimes = array();
foreach ($objects as $object)
{
if (
isset($object->host_in_downtime) && $object->host_in_downtime ||
isset($object->service_in_downtime) && $object->host_in_downtime
) {
$downtimes[] = true;
}
}
return $downtimes;
}
public function serviceAction()
{
$queries = $this->view->queries;
$services = array();
$comments = array();
$downtimes = array();
$errors = array();
foreach ($this->view->queries as $index => $query) {
if (!array_key_exists('host', $query)) {
$errors[] = 'Query ' . $index . ' misses property host.';
continue;
}
if (!array_key_exists('service', $query)) {
$errors[] = 'Query ' . $index . ' misses property service.';
continue;
}
if ($this->_getParam('service') === '*' && $this->_getParam('host') === '*') {
$services = HostAndServiceStatusView::fromRequest(
$this->_request,
array(
'host_name',
'service_name',
'service_in_downtime',
'service_accepts_passive_checks',
'service_does_active_checks',
'service_notifications_enabled',
$service = Service::fetch($this->backend, $query['host'], $query['service']);
foreach ($service->comments as $comment) {
$comments[$comment->comment_id] = null;
// TODO: Flag misses in HostAndServiceStatus
'service_obsessing',
'service_event_handler_enabled',
'service_flap_detection_enabled'
// <<
)
)->getQuery()->fetchAll();
$comments = array_keys($this->getUniqueValues(
CommentView::fromRequest($this->_request)->getQuery()->fetchAll(),
'comment_id'
));
} else {
// fetch specified hosts
foreach ($queries as $index => $query) {
if (!array_key_exists('host', $query)) {
$errors[] = 'Query ' . $index . ' misses property host.';
continue;
}
if (!array_key_exists('service', $query)) {
$errors[] = 'Query ' . $index . ' misses property service.';
continue;
}
$services[] = Service::fetch($this->backend, $query['host'], $query['service']);
}
if ($service->service_in_downtime) {
$downtimes[] += $service->service_in_downtime;
}
$hostnames[] = $service->host_name;
$services[] = $service;
}
$this->view->objects = $this->view->services = $services;
$this->view->comments = array_keys($comments);
$this->view->downtimes = $downtimes;
$this->view->comments = isset($comments) ? $comments : $this->getComments($services);
$this->view->hostnames = $this->getHostnames($services);
$this->view->downtimes = $this->getDowntimes($services);
$this->view->errors = $errors;
$this->handleConfigurationForm();
$this->view->form->setAction('/icinga2-web/monitoring/multi/service');
}
public function notificationAction()
{
}
public function historyAction()
{
}
/**
* Handle the form to configure configuration flags.
*/
@ -161,6 +233,7 @@ class Monitoring_MultiController extends ActionController
{
$details = $this->_getAllParams();
$objects = array();
foreach ($details as $property => $values) {
if (!is_array($values)) {
continue;

View File

@ -75,9 +75,9 @@ class MultiCommandFlagForm extends Form {
/**
* Initialise the form values with the array of items to configure.
*
* @param array $items The items that will be edited with this form.
* @param mixed $items The items that will be edited with this form.
*/
public function initFromItems(array $items)
public function initFromItems($items)
{
$this->values = $this->valuesFromObjects($items);
$this->buildForm();
@ -107,31 +107,26 @@ class MultiCommandFlagForm extends Form {
*
* @param array $items The items
*/
private function valuesFromObjects(array $items)
private function valuesFromObjects($items)
{
$values = array();
foreach ($items as $item) {
foreach ($this->flags as $key => $unused) {
if (isset($item->{$key})) {
$value = $item->{$key};
// convert strings
if ($value === '1' || $value === '0') {
$value = intval($value);
}
// init key with first value
if (!array_key_exists($key, $values)) {
$values[$key] = $value;
continue;
}
// already a mixed state ?
if ($values[$key] === 'unchanged') {
continue;
}
// values differ?
if ($values[$key] ^ $value) {
$values[$key] = 'unchanged';

View File

@ -1,269 +0,0 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
/**
* This file is part of Icinga 2 Web.
*
* Icinga 2 Web - Head for multiple monitoring backends.
* Copyright (C) 2013 Icinga Development Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* @copyright 2013 Icinga Development Team <info@icinga.org>
* @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2
* @author Icinga Development Team <info@icinga.org>
*/
// {{{ICINGA_LICENSE_HEADER}}}
/**
* Class Zend_View_Helper_CommandButton
*
* TODO: Check if it should eventually be implement as a widget
*/
class Zend_View_Helper_CommandButton extends Zend_View_Helper_Abstracts {
/**
* Available command buttons.
*/
const CMD_DISABLE_ACTIVE_CHECKS = 1;
const CMD_ENABLE_ACTIVE_CHECKS = 2;
const CMD_RESCHEDULE_NEXT_CHECK = 3;
const CMD_SUBMIT_PASSIVE_CHECK_RESULT = 4;
const CMD_STOP_OBSESSING = 5;
const CMD_START_OBSESSING = 6;
const CMD_STOP_ACCEPTING_PASSIVE_CHECKS = 7;
const CMD_START_ACCEPTING_PASSIVE_CHECKS = 8;
const CMD_DISABLE_NOTIFICATIONS = 9;
const CMD_ENABLE_NOTIFICATIONS = 10;
const CMD_SEND_CUSTOM_NOTIFICATION = 11;
const CMD_SCHEDULE_DOWNTIME = 12;
const CMD_SCHEDULE_DOWNTIMES_TO_ALL = 13;
const CMD_REMOVE_DOWNTIMES_FROM_ALL = 14;
const CMD_DISABLE_NOTIFICATIONS_FOR_ALL = 15;
const CMD_ENABLE_NOTIFICATIONS_FOR_ALL = 16;
const CMD_RESCHEDULE_NEXT_CHECK_TO_ALL = 17;
const CMD_DISABLE_ACTIVE_CHECKS_FOR_ALL = 18;
const CMD_ENABLE_ACTIVE_CHECKS_FOR_ALL = 19;
const CMD_DISABLE_EVENT_HANDLER = 20;
const CMD_ENABLE_EVENT_HANDLER = 21;
const CMD_DISABLE_FLAP_DETECTION = 22;
const CMD_ENABLE_FLAP_DETECTION = 23;
const CMD_ADD_COMMENT = 24;
const CMD_RESET_ATTRIBUTES = 25;
const CMD_ACKNOWLEDGE_PROBLEM = 26;
const CMD_REMOVE_ACKNOWLEDGEMENT = 27;
const CMD_DELAY_NOTIFICATION = 28;
const CMD_REMOVE_DOWNTIME = 29;
/**
* Render the given command-button
*
* @param $command The command constant, for example CommandButton::CMD_DISABLE_ACTIVE_CHECKS
* @param $href The href that should be executed when clicking this button.
*/
private function render($command, $href) {
$cmd = $this->commandInformation[$command];
}
/**
* Information about interface commands
*
* With following structure
* <pre>
* array(
* self::CMD_CONSTANT_* => array(
* '<LONG DESCRIPTION WHERE %s is the type, e.g. host or service>',
* '<SHORT DESCRIPTION WHERE %s is the type, e.g. host or service>',
* '[ICON CSS CLASS]',
* '[BUTTON CSS CLASS]',
*
* // Maybe any other options later on
* )
* )
* </pre>
*
* @var array
*/
private static $commandInformation = array(
self::CMD_DISABLE_ACTIVE_CHECKS => array(
'Disable Active Checks For This %s',
'Disable Active Checks',
'',
''
),
self::CMD_ENABLE_ACTIVE_CHECKS => array(
'Enable Active Checks For This %s',
'Enable Active Checks',
'',
''
),
self::CMD_RESCHEDULE_NEXT_CHECK => array(
'Reschedule Next Service Check',
'Recheck',
'',
'btn-success'
),
self::CMD_SUBMIT_PASSIVE_CHECK_RESULT => array(
'Submit Passive Check Result',
'Submit Check Result',
'',
''
),
self::CMD_STOP_OBSESSING => array(
'Stop Obsessing Over This %s',
'Stop Obsessing',
'',
''
),
self::CMD_START_OBSESSING => array(
'Start Obsessing Over This %s',
'Start Obsessing',
'',
''
),
self::CMD_STOP_ACCEPTING_PASSIVE_CHECKS => array(
'Stop Accepting Passive Checks For This %s',
'Stop Passive Checks',
'',
''
),
self::CMD_START_ACCEPTING_PASSIVE_CHECKS => array(
'Start Accepting Passive Checks For This %s',
'Start Passive Checks',
'',
''
),
self::CMD_DISABLE_NOTIFICATIONS => array(
'Disable Notifications For This %s',
'Disable Notifications',
'',
''
),
self::CMD_ENABLE_NOTIFICATIONS => array(
'Enable Notifications For This %s',
'Enable Notifications',
'',
''
),
self::CMD_SEND_CUSTOM_NOTIFICATION => array(
'Send Custom %s Notification',
'Send Notification',
'',
''
),
self::CMD_SCHEDULE_DOWNTIME => array(
'Schedule Downtime For This %s',
'Schedule Downtime',
'',
''
),
self::CMD_SCHEDULE_DOWNTIMES_TO_ALL => array(
'Schedule Downtime For This %s And All Services',
'Schedule Services Downtime',
'',
''
),
self::CMD_REMOVE_DOWNTIMES_FROM_ALL => array(
'Remove Downtime(s) For This %s And All Services',
'Remove Downtime(s)',
'',
''
),
self::CMD_DISABLE_NOTIFICATIONS_FOR_ALL => array(
'Disable Notification For All Service On This %s',
'Disable Service Notifications',
'',
''
),
self::CMD_ENABLE_NOTIFICATIONS_FOR_ALL => array(
'Enable Notification For All Service On This %s',
'Enable Service Notifications',
'',
''
),
self::CMD_RESCHEDULE_NEXT_CHECK_TO_ALL => array(
'Schedule a Check Of All Service On This %s',
'Recheck All Services',
'',
'btn-success'
),
self::CMD_DISABLE_ACTIVE_CHECKS_FOR_ALL => array(
'Disable Checks For All Services On This %s',
'Disable Service Checks',
'',
''
),
self::CMD_ENABLE_ACTIVE_CHECKS_FOR_ALL => array(
'Enable Checks For All Services On This %s',
'Enable Service Checks',
'',
''
),
self::CMD_DISABLE_EVENT_HANDLER => array(
'Disable Event Handler For This %s',
'Disable Event Handler',
'',
''
),
self::CMD_ENABLE_EVENT_HANDLER => array(
'Enable Event Handler For This %s',
'Enable Event Handler',
'',
''
),
self::CMD_DISABLE_FLAP_DETECTION => array(
'Disable Flap Detection For This %s',
'Disable Flap Detection',
'',
''
),
self::CMD_ENABLE_FLAP_DETECTION => array(
'Enable Flap Detection For This %s',
'Enable Flap Detection',
'',
''
),
self::CMD_ADD_COMMENT => array(
'Add New %s Comment',
'Add Comment',
'',
''
),
self::CMD_RESET_ATTRIBUTES => array(
'Reset Modified Attributes',
'Reset Attributes',
'',
'btn-danger'
),
self::CMD_ACKNOWLEDGE_PROBLEM => array(
'Acknowledge %s Problem',
'Acknowledge',
'',
'btn-warning'
),
self::CMD_REMOVE_ACKNOWLEDGEMENT => array(
'Remove %s Acknowledgement',
'Remove Acknowledgement',
'',
'btn-warning'
),
self::CMD_DELAY_NOTIFICATION => array(
'Delay Next %s Notification',
'Delay Notification',
'',
''
)
);
}

View File

@ -12,7 +12,7 @@ $viewHelper = $this->getHelper('MonitoringState');
<h1>Comments</h1>
<div data-icinga-component="app/mainDetailGrid">
<div data-icinga-component="app/mainDetailGrid" data-icinga-grid-selection-type="multi">
<?= $this->sortControl->render($this); ?>
<?= $this->paginationControl($comments, null, null, array('preserve' => $this->preserve)); ?>

View File

@ -5,7 +5,7 @@ $viewHelper = $this->getHelper('MonitoringState');
$knownGroups = array()
?>
<div data-icinga-component="app/mainDetailGrid">
<div data-icinga-component="app/mainDetailGrid" data-icinga-grid-selection-type="none">
<?= $this->sortControl->render($this); ?>
<?= $this->paginationControl($contactgroups, null, null, array('preserve' => $this->preserve)); ?>
<table class="table table-condensed">

View File

@ -5,7 +5,7 @@ $viewHelper = $this->getHelper('MonitoringState');
$contactHelper = $this->getHelper('ContactFlags');
?>
<div data-icinga-component="app/mainDetailGrid">
<div data-icinga-component="app/mainDetailGrid" data-icinga-grid-selection-type="none">
<?= $this->sortControl->render($this); ?>
<?= $this->paginationControl($contacts, null, null, array('preserve' => $this->preserve)); ?>
<table class="table table-condensed">

View File

@ -17,7 +17,7 @@ $commandHelper = $this->getHelper('CommandForm');
<?= $this->tabs->render($this); ?>
<div data-icinga-component="app/mainDetailGrid">
<div data-icinga-component="app/mainDetailGrid" data-icinga-grid-selection-type="none">
<div>
<?= $this->sortControl->render($this); ?>
</div>

View File

@ -1,4 +1,4 @@
<div data-icinga-component="app/mainDetailGrid">
<div data-icinga-component="app/mainDetailGrid" data-icinga-grid-selection-type="single">
<?= $this->sortControl->render($this); ?>
<?= $this->paginationControl($hostgroups, null, null, array('preserve' => $this->preserve)); ?>
<table class="table table-condensed">

View File

@ -4,7 +4,7 @@ $viewHelper = $this->getHelper('MonitoringState');
<?= $this->tabs->render($this); ?>
<h1>Hosts Status</h1>
<div data-icinga-component="app/mainDetailGrid">
<div data-icinga-component="app/mainDetailGrid" data-icinga-grid-selection-type="multi">
<div class="container">
<div class="row">
<div class="col-md-5">
@ -17,6 +17,9 @@ $viewHelper = $this->getHelper('MonitoringState');
<div class="row">
<?= $this->paginationControl($hosts, null, null, array('preserve' => $this->preserve)); ?>
</div>
Select
<a href='?detail=<?= urlencode($this->href('monitoring/multi/host',array( 'host' => '*' )))?>'> All </a>
<a href='?'> None </a>
</div>
<table class="table table-condensed">

View File

@ -3,7 +3,7 @@
<?php
$formatter = $this->getHelper('MonitoringProperties');
?>
<div data-icinga-component="app/mainDetailGrid">
<div data-icinga-component="app/mainDetailGrid" data-icinga-grid-selection-type="multi">
<?= $this->sortControl->render($this); ?>
<?= $this->paginationControl($notifications, null, null, array('preserve' => $this->preserve)); ?>

View File

@ -1,4 +1,4 @@
<div data-icinga-component="app/mainDetailGrid">
<div data-icinga-component="app/mainDetailGrid" data-icinga-grid-selection-type="single">
<?= $this->sortControl->render($this); ?>
<?= $this->paginationControl($servicegroups, null, null, array('preserve' => $this->preserve)); ?>
<table>

View File

@ -4,7 +4,7 @@ $viewHelper = $this->getHelper('MonitoringState');
<?= $this->tabs->render($this); ?>
<h1>Services Status</h1>
<div data-icinga-component="app/mainDetailGrid">
<div data-icinga-component="app/mainDetailGrid" data-icinga-grid-selection-type="multi">
<div class="container">
<div class="row">
<div class="col-md-5">
@ -17,6 +17,14 @@ $viewHelper = $this->getHelper('MonitoringState');
<div class="row">
<?= $this->paginationControl($services, null, null, array('preserve' => $this->preserve)); ?>
</div>
Select
<a href='?detail=<?= urlencode(
$this->href(
'monitoring/multi/host',
array('host' => '*', 'service' => '*')
)
)?>'> All </a>
<a href='?'> None </a>
</div>
<table class="table table-condensed">
<tbody>

View File

@ -77,12 +77,19 @@ function(Container, $, logger, Selectable, TableMultiSelection, URI) {
var controlForms;
/**
* Handles multi-selection
* Handles a multi-selection of rows
*
* @type {TableMultiSelection}
*/
var selection;
/**
* Defines how row clicks are handled. Can either be 'none', 'single' or 'multi'
*
* @type {string}
*/
var selectionMode;
/**
* Detect and select control forms for this table and return them
*
@ -128,6 +135,29 @@ function(Container, $, logger, Selectable, TableMultiSelection, URI) {
}
};
/**
* Activate a hover effect on all table rows, to indicate that
* this table row is clickable.
*
* @param domContext
*/
this.activateRowHovering = function(domContext) {
domContext = domContext || contentNode;
//$(domContext).addClass('table-hover');
$('tbody tr', domContext).hover(
function(e) {
$(this).addClass('hover');
e.preventDefault();
e.stopPropagation();
},
function(e) {
$(this).removeClass('hover');
e.preventDefault();
e.stopPropagation();
}
);
};
/**
* Register the row links of tables using the first link found in the table (no matter if visible or not)
*
@ -159,18 +189,30 @@ function(Container, $, logger, Selectable, TableMultiSelection, URI) {
}
}
var selectable = new Selectable(this);
if (ev.ctrlKey || ev.metaKey) {
selection.toggle(selectable);
} else if (ev.shiftKey) {
// select range ?
selection.add(selectable);
} else {
selection.clear();
selection.add(selectable);
switch (selectionMode) {
case 'multi':
var selectable = new Selectable(this);
if (ev.ctrlKey || ev.metaKey) {
selection.toggle(selectable);
} else if (ev.shiftKey) {
// select range ?
selection.add(selectable);
} else {
selection.clear();
selection.add(selectable);
}
break;
case 'single':
selection.clear();
selection.add(new Selectable(this));
break;
case 'none':
// don't open the link
return;
}
// TODO: Detail target
var url = URI($('a', this).attr('href'));
var segments = url.segment();
if (selection.size() === 0) {
@ -261,10 +303,14 @@ function(Container, $, logger, Selectable, TableMultiSelection, URI) {
this.container.removeDefaultLoadIndicator();
controlForms = determineControlForms();
contentNode = determineContentTable();
selection = new TableMultiSelection(
contentNode,
Container.getDetailContainer().getContainerHref()
);
selectionMode = gridDomNode.data('icinga-grid-selection-type');
if (selectionMode === 'multi' || selectionMode === 'single') {
this.activateRowHovering();
selection = new TableMultiSelection(
contentNode,
Container.getDetailContainer().getContainerHref()
);
}
this.registerControls();
this.registerTableLinks();
this.registerHistoryChanges();

View File

@ -0,0 +1,39 @@
// {{{ICINGA_LICENSE_HEADER}}}
/**
* This file is part of Icinga 2 Web.
*
* Icinga 2 Web - Head for multiple monitoring backends.
* Copyright (C) 2013 Icinga Development Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* @copyright 2013 Icinga Development Team <info@icinga.org>
* @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2
* @author Icinga Development Team <info@icinga.org>
*/
// {{{ICINGA_LICENSE_HEADER}}}
/*global Icinga:false, document: false, define:false require:false base_url:false console:false */
/**
* Ensures that our date/time controls will work on every browser (natively or javascript based)
*/
define(['jquery', 'datetimepicker'], function($) {
"use strict";
var triStateCheckbox = function(target) {
// TODO: remove radio button group from form and add a tri-state checkbox
};
return triStateCheckbox;
});