1<?php 2/********************************************************************* 3 class.dynamic_forms.php 4 5 Forms models built on the VerySimpleModel paradigm. Allows for arbitrary 6 data to be associated with tickets. Eventually this model can be 7 extended to associate arbitrary data with registered clients and thread 8 entries. 9 10 Jared Hancock <jared@osticket.com> 11 Copyright (c) 2006-2013 osTicket 12 http://www.osticket.com 13 14 Released under the GNU General Public License WITHOUT ANY WARRANTY. 15 See LICENSE.TXT for details. 16 17 vim: expandtab sw=4 ts=4 sts=4: 18**********************************************************************/ 19require_once(INCLUDE_DIR . 'class.orm.php'); 20require_once(INCLUDE_DIR . 'class.forms.php'); 21require_once(INCLUDE_DIR . 'class.list.php'); 22require_once(INCLUDE_DIR . 'class.filter.php'); 23require_once(INCLUDE_DIR . 'class.signal.php'); 24 25/** 26 * Form template, used for designing the custom form and for entering custom 27 * data for a ticket 28 */ 29class DynamicForm extends VerySimpleModel { 30 31 static $meta = array( 32 'table' => FORM_SEC_TABLE, 33 'ordering' => array('title'), 34 'pk' => array('id'), 35 'joins' => array( 36 'fields' => array( 37 'reverse' => 'DynamicFormField.form', 38 ), 39 ), 40 ); 41 42 // Registered form types 43 static $types = array( 44 'T' => 'Ticket Information', 45 'U' => 'User Information', 46 'O' => 'Organization Information', 47 ); 48 49 const FLAG_DELETABLE = 0x0001; 50 const FLAG_DELETED = 0x0002; 51 52 var $_form; 53 var $_fields; 54 var $_has_data = false; 55 var $_dfields; 56 57 function getInfo() { 58 $base = $this->ht; 59 unset($base['fields']); 60 return $base; 61 } 62 63 function getId() { 64 return $this->id; 65 } 66 67 /** 68 * Fetch a list of field implementations for the fields defined in this 69 * form. This method should *always* be preferred over 70 * ::getDynamicFields() to avoid caching confusion 71 */ 72 function getFields() { 73 if (!$this->_fields) { 74 $this->_fields = new ListObject(); 75 foreach ($this->getDynamicFields() as $f) 76 $this->_fields->append($f->getImpl($f)); 77 } 78 return $this->_fields; 79 } 80 81 /** 82 * Fetch the dynamic fields associated with this dynamic form. Do not 83 * use this list for data processing or validation. Use ::getFields() 84 * for that. 85 */ 86 function getDynamicFields() { 87 return $this->fields; 88 } 89 90 // Multiple inheritance -- delegate methods not defined to a forms API 91 // Form 92 function __call($what, $args) { 93 $delegate = array($this->getForm(), $what); 94 if (!is_callable($delegate)) 95 throw new Exception(sprintf(__('%s: Call to non-existing function'), $what)); 96 return call_user_func_array($delegate, $args); 97 } 98 99 function getTitle() { 100 return $this->getLocal('title'); 101 } 102 103 function getInstructions() { 104 return $this->getLocal('instructions'); 105 } 106 107 /** 108 * Drop field errors clean info etc. Useful when replacing the source 109 * content of the form. This is necessary because the field listing is 110 * cached under some circumstances. 111 */ 112 function reset() { 113 foreach ($this->getFields() as $f) 114 $f->reset(); 115 return $this; 116 } 117 118 function getForm($source=false) { 119 if ($source) 120 $this->reset(); 121 $fields = $this->getFields(); 122 $form = new SimpleForm($fields, $source, array( 123 'title' => $this->getLocal('title'), 124 'instructions' => Format::htmldecode($this->getLocal('instructions')), 125 'id' => $this->getId(), 126 'type' => $this->type ?: null, 127 )); 128 return $form; 129 } 130 131 function hasFlag($flag) { 132 return (isset($this->flags) && ($this->flags & $flag) != 0); 133 } 134 135 function isDeleted() { 136 return $this->hasFlag(self::FLAG_DELETED); 137 } 138 139 function isDeletable() { 140 return $this->hasFlag(self::FLAG_DELETABLE); 141 } 142 143 function setFlag($flag) { 144 $this->flags |= $flag; 145 } 146 147 function hasAnyVisibleFields($user=false) { 148 global $thisstaff, $thisclient; 149 $user = $user ?: $thisstaff ?: $thisclient; 150 $visible = 0; 151 $isstaff = $user instanceof Staff; 152 foreach ($this->getFields() as $F) { 153 if ($isstaff) { 154 if ($F->isVisibleToStaff()) 155 $visible++; 156 } 157 elseif ($F->isVisibleToUsers()) { 158 $visible++; 159 } 160 } 161 return $visible > 0; 162 } 163 164 function instanciate($sort=1, $data=null) { 165 $inst = DynamicFormEntry::create( 166 array('form_id'=>$this->get('id'), 'sort'=>$sort) 167 ); 168 169 if ($data) 170 $inst->setSource($data); 171 172 $inst->_fields = $this->_fields ?: null; 173 174 return $inst; 175 } 176 177 function disableFields(array $ids) { 178 foreach ($this->getFields() as $F) { 179 if (in_array($F->get('id'), $ids)) { 180 $F->disable(); 181 } 182 } 183 } 184 185 function getTranslateTag($subtag) { 186 return _H(sprintf('form.%s.%s', $subtag, $this->id)); 187 } 188 function getLocal($subtag) { 189 $tag = $this->getTranslateTag($subtag); 190 $T = CustomDataTranslation::translate($tag); 191 return $T != $tag ? $T : $this->get($subtag); 192 } 193 194 function save($refetch=false) { 195 if (count($this->dirty)) 196 $this->set('updated', new SqlFunction('NOW')); 197 if ($rv = parent::save($refetch | $this->dirty)) 198 return $this->saveTranslations(); 199 return $rv; 200 } 201 202 function delete() { 203 204 if (!$this->isDeletable()) 205 return false; 206 207 // Soft Delete: Mark the form as deleted. 208 $this->setFlag(self::FLAG_DELETED); 209 $type = array('type' => 'deleted'); 210 Signal::send('object.deleted', $this, $type); 211 return $this->save(); 212 } 213 214 function getExportableFields($exclude=array(), $prefix='__') { 215 $fields = array(); 216 foreach ($this->getFields() as $f) { 217 // Ignore core fields 218 if ($exclude && in_array($f->get('name'), $exclude)) 219 continue; 220 // Ignore non-data fields 221 // FIXME: Consider ::isStorable() too 222 elseif (!$f->hasData() || $f->isPresentationOnly()) 223 continue; 224 225 $name = $f->get('name') ?: ('field_'.$f->get('id')); 226 $fields[$prefix.$name] = $f; 227 } 228 return $fields; 229 } 230 231 static function create($ht=false) { 232 $inst = new static($ht); 233 $inst->set('created', new SqlFunction('NOW')); 234 if (isset($ht['fields'])) { 235 $inst->save(); 236 foreach ($ht['fields'] as $f) { 237 $field = DynamicFormField::create(array('form' => $inst) + $f); 238 $field->save(); 239 } 240 } 241 return $inst; 242 } 243 244 function saveTranslations($vars=false) { 245 global $thisstaff; 246 247 $vars = $vars ?: $_POST; 248 $tags = array( 249 'title' => $this->getTranslateTag('title'), 250 'instructions' => $this->getTranslateTag('instructions'), 251 ); 252 $rtags = array_flip($tags); 253 $translations = CustomDataTranslation::allTranslations($tags, 'phrase'); 254 foreach ($translations as $t) { 255 $T = $rtags[$t->object_hash]; 256 $content = @$vars['trans'][$t->lang][$T]; 257 if (!isset($content)) 258 continue; 259 260 // Content is not new and shouldn't be added below 261 unset($vars['trans'][$t->lang][$T]); 262 263 $t->text = $content; 264 $t->agent_id = $thisstaff->getId(); 265 $t->updated = SqlFunction::NOW(); 266 if (!$t->save()) 267 return false; 268 } 269 // New translations (?) 270 if ($vars['trans'] && is_array($vars['trans'])) { 271 foreach ($vars['trans'] as $lang=>$parts) { 272 if (!Internationalization::isLanguageEnabled($lang)) 273 continue; 274 foreach ($parts as $T => $content) { 275 $content = trim($content); 276 if (!$content) 277 continue; 278 $t = CustomDataTranslation::create(array( 279 'type' => 'phrase', 280 'object_hash' => $tags[$T], 281 'lang' => $lang, 282 'text' => $content, 283 'agent_id' => $thisstaff->getId(), 284 'updated' => SqlFunction::NOW(), 285 )); 286 if (!$t->save()) 287 return false; 288 } 289 } 290 } 291 return true; 292 } 293 294 static function rebuildDynamicDataViews() { 295 return self::ensureDynamicDataViews(true, true); 296 } 297 298 // ensure cdata tables exists 299 static function ensureDynamicDataViews($build=true, $force=false) { 300 $forms = ['TicketForm', 'TaskForm', 'UserForm', 'OrganizationForm']; 301 foreach ($forms as $form) { 302 if ($force && $build) 303 $form::dropDynamicDataView(false); 304 $form::ensureDynamicDataView($build); 305 } 306 } 307 308 static function ensureCdataTables($obj, $data) { 309 // Only perfrom check on real cron call, not autocrons triggered on 310 // agents activity. 311 if ($data['autocron'] === false) 312 self::ensureDynamicDataViews(true); 313 } 314 315 static function ensureDynamicDataView($build=false, $croak=true) { 316 317 if (!($cdata=static::$cdata) || !$cdata['table']) 318 return false; 319 320 $sql = 'SHOW TABLES LIKE \''.$cdata['table'].'\''; 321 // Return true if the cdata table exists 322 if (db_num_rows(db_query($sql))) 323 return true; 324 325 if (!$build && $croak) 326 die(sprintf('%s. %s.', 327 __('Missing CDATA table'), 328 __('Get technical support'))); 329 330 return $build ? static::buildDynamicDataView($cdata) : false; 331 } 332 333 static function buildDynamicDataView($cdata) { 334 $sql = 'CREATE TABLE IF NOT EXISTS `'.$cdata['table'].'` (PRIMARY KEY 335 ('.$cdata['object_id'].')) DEFAULT CHARSET=utf8 AS ' 336 . static::getCrossTabQuery( $cdata['object_type'], $cdata['object_id']); 337 db_query($sql); 338 } 339 340 static function dropDynamicDataView($rebuild=true) { 341 342 if (!($cdata=static::$cdata) || !$cdata['table']) 343 return false; 344 345 $sql = 'DROP TABLE IF EXISTS `'.$cdata['table'].'`'; 346 if (!db_query($sql)) 347 return false; 348 349 return $rebuild ? static::ensureDynamicDataView($rebuild, false) : 350 true; 351 } 352 353 static function updateDynamicDataView($answer, $data) { 354 // TODO: Detect $data['dirty'] for value and value_id 355 // We're chiefly concerned with Ticket form answers 356 357 $cdata = static::$cdata; 358 if (!$cdata 359 || !$cdata['table'] 360 || !($e = $answer->getEntry()) 361 || $e->form->get('type') != $cdata['object_type']) 362 return; 363 364 // $record = array(); 365 // $record[$f] = $answer->value' 366 // TicketFormData::objects()->filter(array('ticket_id'=>$a)) 367 // ->merge($record); 368 $sql = 'SHOW TABLES LIKE \''.$cdata['table'].'\''; 369 if (!db_num_rows(db_query($sql))) 370 return; 371 372 $f = $answer->getField(); 373 $name = $f->get('name') ? $f->get('name') 374 : 'field_'.$f->get('id'); 375 $fields = sprintf('`%s`=', $name) . db_input($answer->getSearchKeys()); 376 $sql = 'INSERT INTO `'.$cdata['table'].'` SET '.$fields 377 . sprintf(', `%s`= %s', 378 $cdata['object_id'], 379 db_input($answer->getEntry()->get('object_id'))) 380 .' ON DUPLICATE KEY UPDATE '.$fields; 381 db_query($sql); 382 } 383 384 static function updateDynamicFormEntryAnswer($answer, $data) { 385 if (!$answer 386 || !($e = $answer->getEntry()) 387 || !$e->form) 388 return; 389 390 switch ($e->form->get('type')) { 391 case 'T': 392 return TicketForm::updateDynamicDataView($answer, $data); 393 case 'A': 394 return TaskForm::updateDynamicDataView($answer, $data); 395 case 'U': 396 return UserForm::updateDynamicDataView($answer, $data); 397 case 'O': 398 return OrganizationForm::updateDynamicDataView($answer, $data); 399 } 400 401 } 402 403 static function updateDynamicFormField($field, $data) { 404 if (!$field || !$field->form) 405 return; 406 407 switch ($field->form->get('type')) { 408 case 'T': 409 return TicketForm::dropDynamicDataView(); 410 case 'A': 411 return TaskForm::dropDynamicDataView(); 412 case 'U': 413 return UserForm::dropDynamicDataView(); 414 case 'O': 415 return OrganizationForm::dropDynamicDataView(); 416 } 417 418 } 419 420 static function getCrossTabQuery($object_type, $object_id='object_id', $exclude=array()) { 421 $fields = static::getDynamicDataViewFields($exclude); 422 return "SELECT entry.`object_id` as `$object_id`, ".implode(',', $fields) 423 .' FROM '.FORM_ENTRY_TABLE.' entry 424 JOIN '.FORM_ANSWER_TABLE.' ans ON ans.entry_id = entry.id 425 JOIN '.FORM_FIELD_TABLE." field ON field.id=ans.field_id 426 WHERE entry.object_type='$object_type' GROUP BY entry.object_id"; 427 } 428 429 // Materialized View for custom data (MySQL FlexViews would be nice) 430 // 431 // @see http://code.google.com/p/flexviews/ 432 static function getDynamicDataViewFields($exclude) { 433 $fields = array(); 434 foreach (static::getInstance()->getFields() as $f) { 435 if ($exclude && in_array($f->get('name'), $exclude)) 436 continue; 437 438 $impl = $f->getImpl($f); 439 if (!$impl->hasData() || $impl->isPresentationOnly()) 440 continue; 441 442 $id = $f->get('id'); 443 $name = ($f->get('name')) ? $f->get('name') 444 : 'field_'.$id; 445 446 if ($impl instanceof ChoiceField || $impl instanceof SelectionField) { 447 $fields[] = sprintf( 448 'MAX(CASE WHEN field.id=\'%1$s\' THEN REPLACE(REPLACE(REPLACE(REPLACE(coalesce(ans.value_id, ans.value), \'{\', \'\'), \'}\', \'\'), \'"\', \'\'), \':\', \',\') ELSE NULL END) as `%2$s`', 449 $id, $name); 450 } 451 else { 452 $fields[] = sprintf( 453 'MAX(IF(field.id=\'%1$s\',coalesce(ans.value_id, ans.value),NULL)) as `%2$s`', 454 $id, $name); 455 } 456 } 457 return $fields; 458 } 459 460 461 462} 463 464class UserForm extends DynamicForm { 465 static $instance; 466 static $form; 467 468 static $cdata = array( 469 'table' => USER_CDATA_TABLE, 470 'object_id' => 'user_id', 471 'object_type' => ObjectModel::OBJECT_TYPE_USER, 472 ); 473 474 static function objects() { 475 $os = parent::objects(); 476 return $os->filter(array('type'=>'U')); 477 } 478 479 static function getUserForm() { 480 if (!isset(static::$form)) { 481 static::$form = static::objects()->one(); 482 } 483 return static::$form; 484 } 485 486 static function getInstance() { 487 if (!isset(static::$instance)) 488 static::$instance = static::getUserForm()->instanciate(); 489 return static::$instance; 490 } 491 492 static function getNewInstance() { 493 $o = static::objects()->one(); 494 static::$instance = $o->instanciate(); 495 return static::$instance; 496 } 497} 498Filter::addSupportedMatches(/* @trans */ 'User Data', function() { 499 $matches = array(); 500 foreach (UserForm::getInstance()->getFields() as $f) { 501 if (!$f->hasData()) 502 continue; 503 $matches['field.'.$f->get('id')] = __('User').' / '.$f->getLabel(); 504 if (($fi = $f->getImpl()) && $fi->hasSubFields()) { 505 foreach ($fi->getSubFields() as $p) { 506 $matches['field.'.$f->get('id').'.'.$p->get('id')] 507 = __('User').' / '.$f->getLabel().' / '.$p->getLabel(); 508 } 509 } 510 } 511 return $matches; 512}, 20); 513 514class TicketForm extends DynamicForm { 515 static $instance; 516 517 static $cdata = array( 518 'table' => TICKET_CDATA_TABLE, 519 'object_id' => 'ticket_id', 520 'object_type' => 'T', 521 ); 522 523 static function objects() { 524 $os = parent::objects(); 525 return $os->filter(array('type'=>'T')); 526 } 527 528 static function getInstance() { 529 if (!isset(static::$instance)) 530 self::getNewInstance(); 531 return static::$instance; 532 } 533 534 static function getNewInstance() { 535 $o = static::objects()->one(); 536 static::$instance = $o->instanciate(); 537 return static::$instance; 538 } 539 540} 541// Add fields from the standard ticket form to the ticket filterable fields 542Filter::addSupportedMatches(/* @trans */ 'Ticket Data', function() { 543 $matches = array(); 544 foreach (TicketForm::getInstance()->getFields() as $f) { 545 if (!$f->hasData()) 546 continue; 547 $matches['field.'.$f->get('id')] = __('Ticket').' / '.$f->getLabel(); 548 if (($fi = $f->getImpl()) && $fi->hasSubFields()) { 549 foreach ($fi->getSubFields() as $p) { 550 $matches['field.'.$f->get('id').'.'.$p->get('id')] 551 = __('Ticket').' / '.$f->getLabel().' / '.$p->getLabel(); 552 } 553 } 554 } 555 return $matches; 556}, 30); 557// Manage materialized view on custom data updates 558Signal::connect('model.created', 559 array('DynamicForm', 'updateDynamicFormEntryAnswer'), 560 'DynamicFormEntryAnswer'); 561Signal::connect('model.updated', 562 array('DynamicForm', 'updateDynamicFormEntryAnswer'), 563 'DynamicFormEntryAnswer'); 564// Recreate the dynamic view after new or removed fields to the ticket 565// details form 566Signal::connect('model.created', 567 array('DynamicForm', 'updateDynamicFormField'), 568 'DynamicFormField'); 569Signal::connect('model.deleted', 570 array('DynamicForm', 'updateDynamicFormField'), 571 'DynamicFormField'); 572// If the `name` column is in the dirty list, we would be renaming a 573// column. Delete the view instead. 574Signal::connect('model.updated', 575 array('DynamicForm', 'updateDynamicFormField'), 576 'DynamicFormField', 577 function($o, $d) { return isset($d['dirty']) 578 && (isset($d['dirty']['name']) || isset($d['dirty']['type'])); }); 579 580// Check to make sure cdata tables exists 581Signal::connect('cron', array('DynamicForm', 'ensureCdataTables')); 582 583Filter::addSupportedMatches(/* trans */ 'Custom Forms', function() { 584 $matches = array(); 585 foreach (DynamicForm::objects()->filter(array('type'=>'G')) as $form) { 586 foreach ($form->getFields() as $f) { 587 if (!$f->hasData()) 588 continue; 589 $matches['field.'.$f->get('id')] = $form->getTitle().' / '.$f->getLabel(); 590 if (($fi = $f->getImpl()) && $fi->hasSubFields()) { 591 foreach ($fi->getSubFields() as $p) { 592 $matches['field.'.$f->get('id').'.'.$p->get('id')] 593 = $form->getTitle().' / '.$f->getLabel().' / '.$p->getLabel(); 594 } 595 } 596 } 597 } 598 return $matches; 599}, 9900); 600 601require_once(INCLUDE_DIR . "class.json.php"); 602 603class DynamicFormField extends VerySimpleModel { 604 605 static $meta = array( 606 'table' => FORM_FIELD_TABLE, 607 'ordering' => array('sort'), 608 'pk' => array('id'), 609 'select_related' => array('form'), 610 'joins' => array( 611 'form' => array( 612 'null' => true, 613 'constraint' => array('form_id' => 'DynamicForm.id'), 614 ), 615 'answers' => array( 616 'reverse' => 'DynamicFormEntryAnswer.field', 617 ), 618 ), 619 ); 620 621 var $_field; 622 var $_disabled = false; 623 624 const FLAG_ENABLED = 0x00001; 625 const FLAG_EXT_STORED = 0x00002; // Value stored outside of form_entry_value 626 const FLAG_CLOSE_REQUIRED = 0x00004; 627 628 const FLAG_MASK_CHANGE = 0x00010; 629 const FLAG_MASK_DELETE = 0x00020; 630 const FLAG_MASK_EDIT = 0x00040; 631 const FLAG_MASK_DISABLE = 0x00080; 632 const FLAG_MASK_REQUIRE = 0x10000; 633 const FLAG_MASK_VIEW = 0x20000; 634 const FLAG_MASK_NAME = 0x40000; 635 636 const MASK_MASK_INTERNAL = 0x400B2; # !change, !delete, !disable, !edit-name 637 const MASK_MASK_ALL = 0x700F2; 638 639 const FLAG_CLIENT_VIEW = 0x00100; 640 const FLAG_CLIENT_EDIT = 0x00200; 641 const FLAG_CLIENT_REQUIRED = 0x00400; 642 643 const MASK_CLIENT_FULL = 0x00700; 644 645 const FLAG_AGENT_VIEW = 0x01000; 646 const FLAG_AGENT_EDIT = 0x02000; 647 const FLAG_AGENT_REQUIRED = 0x04000; 648 649 const MASK_AGENT_FULL = 0x7000; 650 651 // Multiple inheritance -- delegate methods not defined here to the 652 // forms API FormField instance 653 function __call($what, $args) { 654 return call_user_func_array( 655 array($this->getField(), $what), $args); 656 } 657 658 /** 659 * Fetch a forms API FormField instance which represents this designable 660 * DynamicFormField. 661 */ 662 function getField() { 663 global $thisstaff; 664 665 // Finagle the `required` flag for the FormField instance 666 $ht = $this->ht; 667 $ht['required'] = ($thisstaff) ? $this->isRequiredForStaff() 668 : $this->isRequiredForUsers(); 669 670 if (!isset($this->_field)) 671 $this->_field = new FormField($ht); 672 return $this->_field; 673 } 674 675 function getForm() { return $this->form; } 676 function getFormId() { return $this->form_id; } 677 678 /** 679 * setConfiguration 680 * 681 * Used in the POST request of the configuration process. The 682 * ::getConfigurationForm() method should be used to retrieve a 683 * configuration form for this field. That form should be submitted via 684 * a POST request, and this method should be called in that request. The 685 * data from the POST request will be interpreted and will adjust the 686 * configuration of this field 687 * 688 * Parameters: 689 * vars - POST request / data 690 * errors - (OUT array) receives validation errors of the parsed 691 * configuration form 692 * 693 * Returns: 694 * (bool) true if the configuration was updated, false if there were 695 * errors. If false, the errors were written into the received errors 696 * array. 697 */ 698 function setConfiguration($vars, &$errors=array()) { 699 $config = array(); 700 foreach ($this->getConfigurationForm($vars)->getFields() as $name=>$field) { 701 $config[$name] = $field->to_php($field->getClean()); 702 $errors = array_merge($errors, $field->errors()); 703 } 704 705 if (count($errors)) 706 return false; 707 708 // See if field impl. need to save or override anything 709 $config = $this->getImpl()->to_config($config); 710 $this->set('configuration', JsonDataEncoder::encode($config)); 711 $this->set('hint', Format::sanitize($vars['hint']) ?: NULL); 712 713 return true; 714 } 715 716 function isDeletable() { 717 return !$this->hasFlag(self::FLAG_MASK_DELETE); 718 } 719 function isNameForced() { 720 return $this->hasFlag(self::FLAG_MASK_NAME); 721 } 722 function isPrivacyForced() { 723 return $this->hasFlag(self::FLAG_MASK_VIEW); 724 } 725 function isRequirementForced() { 726 return $this->hasFlag(self::FLAG_MASK_REQUIRE); 727 } 728 729 function isChangeable() { 730 return !$this->hasFlag(self::FLAG_MASK_CHANGE); 731 } 732 733 function isEditable() { 734 return $this->hasFlag(self::FLAG_MASK_EDIT); 735 } 736 function disable() { 737 $this->_disabled = true; 738 } 739 function isEnabled() { 740 return !$this->_disabled && $this->hasFlag(self::FLAG_ENABLED); 741 } 742 743 function hasFlag($flag) { 744 return (isset($this->flags) && ($this->flags & $flag) != 0); 745 } 746 747 /** 748 * Describes the current visibility settings for this field. Returns a 749 * comma-separated, localized list of flag descriptions. 750 */ 751 function getVisibilityDescription() { 752 $F = $this->flags; 753 754 if (!$this->hasFlag(self::FLAG_ENABLED)) 755 return __('Disabled'); 756 757 $impl = $this->getImpl(); 758 759 $hints = array(); 760 $VIEW = self::FLAG_CLIENT_VIEW | self::FLAG_AGENT_VIEW; 761 if (($F & $VIEW) == 0) { 762 $hints[] = __('Hidden'); 763 } 764 elseif (~$F & self::FLAG_CLIENT_VIEW) { 765 $hints[] = __('Internal'); 766 } 767 elseif (~$F & self::FLAG_AGENT_VIEW) { 768 $hints[] = __('For EndUsers Only'); 769 } 770 if ($impl->hasData()) { 771 if ($F & (self::FLAG_CLIENT_REQUIRED | self::FLAG_AGENT_REQUIRED)) { 772 $hints[] = __('Required'); 773 } 774 else { 775 $hints[] = __('Optional'); 776 } 777 if (!($F & (self::FLAG_CLIENT_EDIT | self::FLAG_AGENT_EDIT))) { 778 $hints[] = __('Immutable'); 779 } 780 } 781 return implode(', ', $hints); 782 } 783 function getTranslateTag($subtag) { 784 return _H(sprintf('field.%s.%s', $subtag, $this->id)); 785 } 786 function getLocal($subtag, $default=false) { 787 $tag = $this->getTranslateTag($subtag); 788 $T = CustomDataTranslation::translate($tag); 789 return $T != $tag ? $T : ($default ?: $this->get($subtag)); 790 } 791 792 /** 793 * Fetch a list of names to flag settings to make configuring new fields 794 * a bit easier. 795 * 796 * Returns: 797 * <Array['desc', 'flags']>, where the 'desc' key is a localized 798 * description of the flag set, and the 'flags' key is a bit mask of 799 * flags which should be set on the new field to implement the 800 * requirement / visibility mode. 801 */ 802 function allRequirementModes() { 803 return array( 804 'a' => array('desc' => __('Optional'), 805 'flags' => self::FLAG_CLIENT_VIEW | self::FLAG_AGENT_VIEW 806 | self::FLAG_CLIENT_EDIT | self::FLAG_AGENT_EDIT), 807 'b' => array('desc' => __('Required'), 808 'flags' => self::FLAG_CLIENT_VIEW | self::FLAG_AGENT_VIEW 809 | self::FLAG_CLIENT_EDIT | self::FLAG_AGENT_EDIT 810 | self::FLAG_CLIENT_REQUIRED | self::FLAG_AGENT_REQUIRED), 811 'c' => array('desc' => __('Required for EndUsers'), 812 'flags' => self::FLAG_CLIENT_VIEW | self::FLAG_AGENT_VIEW 813 | self::FLAG_CLIENT_EDIT | self::FLAG_AGENT_EDIT 814 | self::FLAG_CLIENT_REQUIRED), 815 'd' => array('desc' => __('Required for Agents'), 816 'flags' => self::FLAG_CLIENT_VIEW | self::FLAG_AGENT_VIEW 817 | self::FLAG_CLIENT_EDIT | self::FLAG_AGENT_EDIT 818 | self::FLAG_AGENT_REQUIRED), 819 'e' => array('desc' => __('Internal, Optional'), 820 'flags' => self::FLAG_AGENT_VIEW | self::FLAG_AGENT_EDIT), 821 'f' => array('desc' => __('Internal, Required'), 822 'flags' => self::FLAG_AGENT_VIEW | self::FLAG_AGENT_EDIT 823 | self::FLAG_AGENT_REQUIRED), 824 'g' => array('desc' => __('For EndUsers Only'), 825 'flags' => self::FLAG_CLIENT_VIEW | self::FLAG_CLIENT_EDIT 826 | self::FLAG_CLIENT_REQUIRED), 827 ); 828 } 829 830 /** 831 * Fetch a list of valid requirement modes for this field. This list 832 * will be filtered based on flags which are not supported or not 833 * allowed for this field. 834 * 835 * Deprecated: 836 * This was used in previous versions when a drop-down list was 837 * presented for editing a field's visibility. The current software 838 * version presents the drop-down list for new fields only. 839 * 840 * Returns: 841 * <Array['desc', 'flags']> Filtered list from ::allRequirementModes 842 */ 843 function getAllRequirementModes() { 844 $modes = static::allRequirementModes(); 845 if ($this->isPrivacyForced()) { 846 // Required to be internal 847 foreach ($modes as $m=>$info) { 848 if ($info['flags'] & (self::FLAG_CLIENT_VIEW | self::FLAG_AGENT_VIEW)) 849 unset($modes[$m]); 850 } 851 } 852 853 if ($this->isRequirementForced()) { 854 // Required to be required 855 foreach ($modes as $m=>$info) { 856 if ($info['flags'] & (self::FLAG_CLIENT_REQUIRED | self::FLAG_AGENT_REQUIRED)) 857 unset($modes[$m]); 858 } 859 } 860 return $modes; 861 } 862 863 function setRequirementMode($mode) { 864 $modes = $this->getAllRequirementModes(); 865 if (!isset($modes[$mode])) 866 return false; 867 868 $info = $modes[$mode]; 869 $this->set('flags', $info['flags'] | self::FLAG_ENABLED); 870 } 871 872 function isRequiredForStaff() { 873 return $this->hasFlag(self::FLAG_AGENT_REQUIRED); 874 } 875 function isRequiredForUsers() { 876 return $this->hasFlag(self::FLAG_CLIENT_REQUIRED); 877 } 878 function isRequiredForClose() { 879 return $this->hasFlag(self::FLAG_CLOSE_REQUIRED); 880 } 881 function isEditableToStaff() { 882 return $this->isEnabled() 883 && $this->hasFlag(self::FLAG_AGENT_EDIT); 884 } 885 function isVisibleToStaff() { 886 return $this->isEnabled() 887 && $this->hasFlag(self::FLAG_AGENT_VIEW); 888 } 889 function isEditableToUsers() { 890 return $this->isEnabled() 891 && $this->hasFlag(self::FLAG_CLIENT_EDIT); 892 } 893 function isVisibleToUsers() { 894 return $this->isEnabled() 895 && $this->hasFlag(self::FLAG_CLIENT_VIEW); 896 } 897 898 function addToQuery($query, $name=false) { 899 return $query->values($name ?: $this->get('name')); 900 } 901 902 /** 903 * Used when updating the form via the admin panel. This represents 904 * validation on the form field template, not data entered into a form 905 * field of a custom form. The latter would be isValidEntry() 906 */ 907 function isValid() { 908 if (count($this->errors())) 909 return false; 910 if (!$this->get('label')) 911 $this->addError( 912 __("Label is required for custom form fields"), "label"); 913 if (($this->isRequiredForStaff() || $this->isRequiredForUsers()) 914 && !$this->get('name') 915 ) { 916 $this->addError( 917 __("Variable name is required for required fields" 918 /* `required` is a visibility setting fields */ 919 /* `variable` is used for automation. Internally it's called `name` */ 920 ), "name"); 921 } 922 if (preg_match('/[.{}\'"`; ]/u', $this->get('name'))) 923 $this->addError(__( 924 'Invalid character in variable name. Please use letters and numbers only.' 925 ), 'name'); 926 return count($this->errors()) == 0; 927 } 928 929 function delete() { 930 $values = $this->answers->count(); 931 932 // Don't really delete form fields with data as that will screw up the data 933 // model. Instead, just drop the association with the form which 934 // will give the appearance of deletion. Not deleting means that 935 // the field will continue to exist on form entries it may already 936 // have answers on, but since it isn't associated with the form, it 937 // won't be available for new form submittals. 938 $this->set('form_id', 0); 939 940 $impl = $this->getImpl(); 941 942 // Trigger db_clean so the field can do house cleaning 943 $impl->db_cleanup(true); 944 945 // Short-circuit deletion if the field has data. 946 if ($impl->hasData() && $values) 947 return $this->save(); 948 949 // Delete the field for realz 950 parent::delete(); 951 952 } 953 954 function save($refetch=false) { 955 if (count($this->dirty)) 956 $this->set('updated', new SqlFunction('NOW')); 957 return parent::save($this->dirty || $refetch); 958 } 959 960 static function create($ht=false) { 961 $inst = new static($ht); 962 $inst->set('created', new SqlFunction('NOW')); 963 if (isset($ht['configuration'])) 964 $inst->configuration = JsonDataEncoder::encode($ht['configuration']); 965 return $inst; 966 } 967} 968 969/** 970 * Represents an entry to a dynamic form. Used to render the completed form 971 * in reference to the attached ticket, etc. A form is used to represent the 972 * template of enterable data. This represents the data entered into an 973 * instance of that template. 974 * 975 * The data of the entry is called 'answers' in this model. This model 976 * represents an instance of a form entry. The data / answers to that entry 977 * are represented individually in the DynamicFormEntryAnswer model. 978 */ 979class DynamicFormEntry extends VerySimpleModel { 980 981 static $meta = array( 982 'table' => FORM_ENTRY_TABLE, 983 'ordering' => array('sort'), 984 'pk' => array('id'), 985 'select_related' => array('form'), 986 'joins' => array( 987 'form' => array( 988 'null' => true, 989 'constraint' => array('form_id' => 'DynamicForm.id'), 990 ), 991 'answers' => array( 992 'reverse' => 'DynamicFormEntryAnswer.entry', 993 ), 994 ), 995 ); 996 997 var $_fields; 998 var $_form; 999 var $_errors = false; 1000 var $_clean = false; 1001 var $_source = null; 1002 1003 function getId() { 1004 return $this->get('id'); 1005 } 1006 function getFormId() { 1007 return $this->form_id; 1008 } 1009 1010 function getAnswers() { 1011 return $this->answers; 1012 } 1013 1014 function getAnswer($name) { 1015 foreach ($this->getAnswers() as $ans) 1016 if ($ans->getField()->get('name') == $name) 1017 return $ans; 1018 return null; 1019 } 1020 1021 function setAnswer($name, $value, $id=false) { 1022 1023 if ($ans=$this->getAnswer($name)) { 1024 $f = $ans->getField(); 1025 if ($f->isStorable()) 1026 $ans->setValue($value, $id); 1027 } 1028 } 1029 1030 function errors() { 1031 return $this->_errors; 1032 } 1033 1034 function getTitle() { 1035 return $this->form->getTitle(); 1036 } 1037 1038 function getInstructions() { 1039 return $this->form->getInstructions(); 1040 } 1041 1042 function getDynamicForm() { 1043 return $this->form; 1044 } 1045 1046 function getForm($source=false, $options=array()) { 1047 if (!isset($this->_form)) { 1048 1049 $fields = $this->getFields(); 1050 if (isset($this->extra)) { 1051 $x = JsonDataParser::decode($this->extra) ?: array(); 1052 foreach ($x['disable'] ?: array() as $id) { 1053 unset($fields[$id]); 1054 } 1055 } 1056 1057 $source = $source ?: $this->getSource(); 1058 $options += array( 1059 'title' => $this->getTitle(), 1060 'instructions' => $this->getInstructions(), 1061 'id' => $this->form_id, 1062 'type' => $this->getDynamicForm()->type ?: null, 1063 ); 1064 $this->_form = new CustomForm($fields, $source, $options); 1065 } 1066 1067 1068 return $this->_form; 1069 } 1070 1071 function getDynamicFields() { 1072 return $this->form->fields; 1073 } 1074 1075 function getMedia() { 1076 return $this->getForm()->getMedia(); 1077 } 1078 1079 function getFields() { 1080 if (!isset($this->_fields)) { 1081 $this->_fields = array(); 1082 // Get all dynamic fields associated with the form 1083 // even when stored elsewhere -- important during validation 1084 foreach ($this->getDynamicFields() as $f) { 1085 $f = $f->getImpl($f); 1086 $this->_fields[$f->get('id')] = $f; 1087 $f->isnew = true; 1088 } 1089 // Include any other answers included in this entry, which may 1090 // be for fields which have since been deleted 1091 foreach ($this->getAnswers() as $a) { 1092 $f = $a->getField(); 1093 $id = $f->get('id'); 1094 if (!isset($this->_fields[$id])) { 1095 // This field is not currently on the associated form 1096 $a->deleted = true; 1097 } 1098 $this->_fields[$id] = $f; 1099 // This field has an answer, so it isn't new (to this entry) 1100 $f->isnew = false; 1101 } 1102 } 1103 return $this->_fields; 1104 } 1105 1106 function filterFields($filter) { 1107 $this->getFields(); 1108 foreach ($this->_fields as $i=>$f) { 1109 if ($filter($f)) 1110 unset($this->_fields[$i]); 1111 } 1112 } 1113 1114 function getSource() { 1115 return $this->_source ?: (isset($this->id) ? false : $_POST); 1116 } 1117 function setSource($source) { 1118 $this->_source = $source; 1119 // Ensure the field is connected to this data source 1120 foreach ($this->getFields() as $F) 1121 if (!$F->getForm()) 1122 $F->setForm($this); 1123 } 1124 1125 function getField($name) { 1126 foreach ($this->getFields() as $field) 1127 if (!strcasecmp($field->get('name'), $name)) 1128 return $field; 1129 1130 return null; 1131 } 1132 1133 /** 1134 * Validate the form and indicate if there no errors. 1135 * 1136 * Parameters: 1137 * $filter - (callback) function to receive each field and return 1138 * boolean true if the field's errors are significant 1139 * $options - options to pass to form and fields. 1140 * 1141 */ 1142 function isValid($filter=false, $options=array()) { 1143 1144 if (!is_array($this->_errors)) { 1145 $form = $this->getForm(false, $options); 1146 $form->isValid($filter); 1147 $this->_errors = $form->errors(); 1148 } 1149 1150 return !$this->_errors; 1151 } 1152 1153 function isValidForClient($update=false) { 1154 $filter = function($f) use($update) { 1155 return $update ? $f->isEditableToUsers() : 1156 $f->isVisibleToUsers(); 1157 }; 1158 return $this->isValid($filter); 1159 } 1160 1161 function isValidForStaff($update=false) { 1162 $filter = function($f) use($update) { 1163 return $update ? $f->isEditableToStaff() : 1164 $f->isVisibleToStaff(); 1165 }; 1166 return $this->isValid($filter); 1167 } 1168 1169 function getClean() { 1170 return $this->getForm()->getClean(); 1171 } 1172 1173 /** 1174 * Compile a list of data used by the filtering system to match dynamic 1175 * content in this entry. This returs an array of `field.<id>` => 1176 * <value> pairs where the <id> is the field id and the <value> is the 1177 * toString() value for the entered data. 1178 * 1179 * If the field returns an array for its ::getFilterData() method, the 1180 * data will be added in the array with the keys prefixed with 1181 * `field.<id>`. This is useful for properties on custom lists, for 1182 * instance, which can contain properties usefule for matching and 1183 * filtering. 1184 */ 1185 function getFilterData() { 1186 $vars = array(); 1187 foreach ($this->getFields() as $f) { 1188 $tag = 'field.'.$f->get('id'); 1189 if ($d = $f->getFilterData()) { 1190 if (is_array($d)) { 1191 foreach ($d as $k=>$v) { 1192 if (is_string($k)) 1193 $vars["$tag$k"] = $v; 1194 else 1195 $vars[$tag] = $v; 1196 } 1197 } 1198 else { 1199 $vars[$tag] = $d; 1200 } 1201 } 1202 } 1203 return $vars; 1204 } 1205 1206 function forTicket($ticket_id, $force=false) { 1207 static $entries = array(); 1208 if (!isset($entries[$ticket_id]) || $force) { 1209 $stuff = DynamicFormEntry::objects() 1210 ->filter(array('object_id'=>$ticket_id, 'object_type'=>'T')); 1211 // If forced, don't cache the result 1212 if ($force) 1213 return $stuff; 1214 $entries[$ticket_id] = &$stuff; 1215 } 1216 return $entries[$ticket_id]; 1217 } 1218 1219 function forTask($id, $force=false) { 1220 static $entries = array(); 1221 if (!isset($entries[$id]) || $force) { 1222 $stuff = DynamicFormEntry::objects()->filter(array( 1223 'object_id' => $id, 1224 'object_type' => ObjectModel::OBJECT_TYPE_TASK 1225 )); 1226 // If forced, don't cache the result 1227 if ($force) 1228 return $stuff; 1229 1230 $entries[$id] = &$stuff; 1231 } 1232 return $entries[$id]; 1233 } 1234 1235 function setTicketId($ticket_id) { 1236 $this->object_type = 'T'; 1237 $this->object_id = $ticket_id; 1238 } 1239 1240 function setClientId($user_id) { 1241 $this->object_type = 'U'; 1242 $this->object_id = $user_id; 1243 } 1244 1245 function setObjectId($object_id) { 1246 $this->object_id = $object_id; 1247 } 1248 1249 function forObject($object_id, $object_type) { 1250 return DynamicFormEntry::objects() 1251 ->filter(array('object_id'=>$object_id, 'object_type'=>$object_type)); 1252 } 1253 1254 function render($options=array()) { 1255 $options += array('staff' => true); 1256 return $this->getForm()->render($options); 1257 } 1258 1259 function getChanges() { 1260 $fields = array(); 1261 foreach ($this->getAnswers() as $a) { 1262 $field = $a->getField(); 1263 if (!$field->hasData() || $field->isPresentationOnly()) 1264 continue; 1265 $changes = $field->getChanges(); 1266 if (!$changes) 1267 continue; 1268 $fields[$field->get('id')] = $changes; 1269 } 1270 return $fields; 1271 } 1272 1273 /** 1274 * addMissingFields 1275 * 1276 * Adds fields that have been added to the linked form (field set) since 1277 * this entry was originally created. If fields are added to the form, 1278 * the method will automatically add the fields and null answers to the 1279 * entry. 1280 */ 1281 function addMissingFields() { 1282 foreach ($this->getFields() as $field) { 1283 if ($field->isnew && $field->isEnabled() 1284 && !$field->isPresentationOnly() 1285 && $field->hasData() 1286 && $field->isStorable() 1287 ) { 1288 $a = new DynamicFormEntryAnswer( 1289 array('field_id'=>$field->get('id'), 'entry'=>$this)); 1290 1291 // Add to list of answers 1292 $this->answers->add($a); 1293 1294 // Omit fields without data and non-storable fields. 1295 if (!$field->hasData() || !$field->isStorable()) 1296 continue; 1297 1298 $a->save(); 1299 } 1300 } 1301 1302 // Sort the form the way it is declared to be sorted 1303 if ($this->_fields) { 1304 uasort($this->_fields, 1305 function($a, $b) { 1306 return $a->get('sort') - $b->get('sort'); 1307 }); 1308 } 1309 } 1310 1311 /** 1312 * Save the form entry and all associated answers. 1313 * 1314 */ 1315 1316 function save($refetch=false) { 1317 return $this->saveAnswers(null, $refetch); 1318 } 1319 1320 /** 1321 * Save the form entry and all associated answers. 1322 * 1323 * Returns: 1324 * (mixed) FALSE if updated failed, otherwise the number of dirty answers 1325 * which were save is returned (which may be ZERO). 1326 */ 1327 1328 function saveAnswers($isEditable=null, $refetch=false) { 1329 if (count($this->dirty)) 1330 $this->set('updated', new SqlFunction('NOW')); 1331 1332 if (!parent::save($refetch || count($this->dirty))) 1333 return false; 1334 1335 $dirty = 0; 1336 foreach ($this->getAnswers() as $a) { 1337 $field = $a->getField(); 1338 // Don't save answers for presentation-only fields or fields 1339 // which are stored elsewhere or those which are not editable 1340 if (!$field->hasData() 1341 || !$field->isStorable() 1342 || $field->isPresentationOnly() 1343 || ($isEditable && !$isEditable($field))) { 1344 continue; 1345 } 1346 // Set the entry here so that $field->getClean() can use the 1347 // entry-id if necessary 1348 $a->entry = $this; 1349 1350 try { 1351 $field->setForm($this); 1352 //for form entry values of file upload fields, we want to save the value as 1353 //json with the file id(s) and file name(s) of each file stored in the field 1354 //so that they display correctly within tasks/tickets 1355 if (get_class($field) == 'FileUploadField') { 1356 //use getChanges if getClean returns an empty array 1357 $fieldClean = $field->getClean() ?: $field->getChanges(); 1358 if (is_array($fieldClean) && $fieldClean[0]) 1359 $fieldClean = json_decode($fieldClean[0], true); 1360 } else 1361 $fieldClean = $field->getClean(); 1362 1363 $val = $field->to_database($fieldClean); 1364 } 1365 catch (FieldUnchanged $e) { 1366 // Don't update the answer. 1367 continue; 1368 } 1369 if (is_array($val)) { 1370 $a->set('value', $val[0]); 1371 $a->set('value_id', $val[1]); 1372 } 1373 else { 1374 $a->set('value', $val); 1375 } 1376 if ($a->dirty) 1377 $dirty++; 1378 $a->save($refetch); 1379 } 1380 return $dirty; 1381 } 1382 1383 function delete() { 1384 if (!parent::delete()) 1385 return false; 1386 1387 foreach ($this->getAnswers() as $a) 1388 $a->delete(); 1389 1390 return true; 1391 } 1392 1393 static function create($ht=false, $data=null) { 1394 $inst = new static($ht); 1395 $inst->set('created', new SqlFunction('NOW')); 1396 if ($data) 1397 $inst->setSource($data); 1398 foreach ($inst->getDynamicFields() as $field) { 1399 if (!($impl = $field->getImpl($field))) 1400 continue; 1401 if (!$impl->hasData() || !$impl->isStorable()) 1402 continue; 1403 $a = new DynamicFormEntryAnswer( 1404 array('field'=>$field, 'entry'=>$inst)); 1405 $a->field->setAnswer($a); 1406 $inst->answers->add($a); 1407 } 1408 return $inst; 1409 } 1410} 1411 1412/** 1413 * Represents a single answer to a single field on a dynamic form. The 1414 * data / answer to the field is linked back to the form and field which was 1415 * originally used for the submission. 1416 */ 1417class DynamicFormEntryAnswer extends VerySimpleModel { 1418 1419 static $meta = array( 1420 'table' => FORM_ANSWER_TABLE, 1421 'ordering' => array('field__sort'), 1422 'pk' => array('entry_id', 'field_id'), 1423 'select_related' => array('field'), 1424 'fields' => array('entry_id', 'field_id', 'value', 'value_id'), 1425 'joins' => array( 1426 'field' => array( 1427 'constraint' => array('field_id' => 'DynamicFormField.id'), 1428 ), 1429 'entry' => array( 1430 'constraint' => array('entry_id' => 'DynamicFormEntry.id'), 1431 ), 1432 ), 1433 ); 1434 1435 var $_field; 1436 var $deleted = false; 1437 var $_value; 1438 1439 function getEntry() { 1440 return $this->entry; 1441 } 1442 1443 function getForm() { 1444 return $this->getEntry()->getForm(); 1445 } 1446 1447 function getField() { 1448 if (!isset($this->_field)) { 1449 $this->_field = $this->field->getImpl($this->field); 1450 $this->_field->setAnswer($this); 1451 } 1452 return $this->_field; 1453 } 1454 1455 function getValue() { 1456 1457 if (!isset($this->_value)) { 1458 //XXX: We're settting the value here to avoid infinite loop 1459 $this->_value = false; 1460 if (isset($this->value)) 1461 $this->_value = $this->getField()->to_php( 1462 $this->get('value'), $this->get('value_id')); 1463 } 1464 1465 return $this->_value; 1466 } 1467 1468 function setValue($value, $id=false) { 1469 $this->getField()->reset(); 1470 $this->_value = null; 1471 $this->set('value', $value); 1472 if ($id !== false) 1473 $this->set('value_id', $id); 1474 } 1475 1476 function getLocal($tag) { 1477 return $this->field->getLocal($tag); 1478 } 1479 1480 function getIdValue() { 1481 return $this->get('value_id'); 1482 } 1483 1484 function isDeleted() { 1485 return $this->deleted; 1486 } 1487 1488 function toString() { 1489 return $this->getField()->toString($this->getValue()); 1490 } 1491 1492 function display() { 1493 return $this->getField()->display($this->getValue()); 1494 } 1495 1496 function getSearchable($include_label=false) { 1497 if ($include_label) 1498 $label = Format::searchable($this->getField()->getLabel()) . " "; 1499 return sprintf("%s%s", $label, 1500 $this->getField()->searchable($this->getValue()) 1501 ); 1502 } 1503 1504 function getSearchKeys() { 1505 return implode(',', (array) $this->getField()->getKeys($this->getValue())); 1506 } 1507 1508 function asVar() { 1509 return $this->getField()->asVar( 1510 $this->get('value'), $this->get('value_id') 1511 ); 1512 } 1513 1514 function getVar($tag) { 1515 if (is_object($var = $this->asVar()) && method_exists($var, 'getVar')) 1516 return $var->getVar($tag); 1517 } 1518 1519 function __toString() { 1520 $v = $this->toString(); 1521 return is_string($v) ? $v : (string) $this->getValue(); 1522 } 1523 1524 function delete() { 1525 if (!parent::delete()) 1526 return false; 1527 1528 // Allow the field to cleanup anything else in the database 1529 $this->getField()->db_cleanup(); 1530 return true; 1531 } 1532 1533 function save($refetch=false) { 1534 if ($this->dirty) 1535 unset($this->_value); 1536 return parent::save($refetch); 1537 } 1538} 1539 1540class SelectionField extends FormField { 1541 static $widget = 'ChoicesWidget'; 1542 1543 function getListId() { 1544 list(,$list_id) = explode('-', $this->get('type')); 1545 return $list_id ?: $this->get('list_id'); 1546 } 1547 1548 function getList() { 1549 if (!$this->_list) 1550 $this->_list = DynamicList::lookup($this->getListId()); 1551 1552 return $this->_list; 1553 } 1554 1555 function getWidget($widgetClass=false) { 1556 $config = $this->getConfiguration(); 1557 if ($config['widget'] == 'typeahead' && $config['multiselect'] == false) 1558 $widgetClass = 'TypeaheadSelectionWidget'; 1559 elseif ($config['widget'] == 'textbox') 1560 $widgetClass = 'TextboxSelectionWidget'; 1561 1562 return parent::getWidget($widgetClass); 1563 } 1564 1565 function display($value) { 1566 global $thisstaff; 1567 1568 if (!is_array($value) 1569 || !$thisstaff // Only agents can preview for now 1570 || !($list=$this->getList())) 1571 return parent::display($value); 1572 1573 $display = array(); 1574 foreach ($value as $k => $v) { 1575 if (is_numeric($k) 1576 && ($i=$list->getItem((int) $k)) 1577 && $i->hasProperties()) 1578 $display[] = $i->display(); 1579 else // Perhaps deleted entry 1580 $display[] = $v; 1581 } 1582 1583 return implode(',', $display); 1584 1585 } 1586 1587 function parse($value) { 1588 1589 if (!($list=$this->getList())) 1590 return null; 1591 1592 $config = $this->getConfiguration(); 1593 $choices = $this->getChoices(); 1594 $selection = array(); 1595 1596 if ($value && !is_array($value)) 1597 $value = array($value); 1598 1599 if ($value && is_array($value)) { 1600 foreach ($value as $k=>$v) { 1601 if ($k && ($i=$list->getItem((int) $k))) 1602 $selection[$i->getId()] = $i->getValue(); 1603 elseif (isset($choices[$k])) 1604 $selection[$k] = $choices[$k]; 1605 elseif (isset($choices[$v])) 1606 $selection[$v] = $choices[$v]; 1607 elseif (($i=$list->getItem($v, true))) 1608 $selection[$i->getId()] = $i->getValue(); 1609 } 1610 } elseif($value) { 1611 //Assume invalid textbox input to be validated 1612 $selection[] = $value; 1613 } 1614 1615 // Don't return an empty array 1616 return $selection ?: null; 1617 } 1618 1619 function to_database($value) { 1620 if (is_array($value)) { 1621 reset($value); 1622 } 1623 if ($value && is_array($value)) 1624 $value = JsonDataEncoder::encode($value); 1625 1626 return $value; 1627 } 1628 1629 function to_php($value, $id=false) { 1630 if (is_string($value)) 1631 $value = JsonDataParser::parse($value) ?: $value; 1632 1633 if (!is_array($value)) { 1634 $values = array(); 1635 $choices = $this->getChoices(); 1636 foreach (explode(',', $value) as $V) { 1637 if (isset($choices[$V])) 1638 $values[$V] = $choices[$V]; 1639 } 1640 if ($id && isset($choices[$id])) 1641 $values[$id] = $choices[$id]; 1642 1643 if ($values) 1644 return $values; 1645 // else return $value unchanged 1646 } 1647 // Don't set the ID here as multiselect prevents using exactly one 1648 // ID value. Instead, stick with the JSON value only. 1649 return $value; 1650 } 1651 1652 function getKeys($value) { 1653 if (!is_array($value)) 1654 $value = $this->getChoice($value); 1655 if (is_array($value)) 1656 return implode(', ', array_keys($value)); 1657 return (string) $value; 1658 } 1659 1660 // PHP 5.4 Move this to a trait 1661 function whatChanged($before, $after) { 1662 $before = (array) $before; 1663 $after = (array) $after; 1664 $added = array_diff($after, $before); 1665 $deleted = array_diff($before, $after); 1666 $added = array_map(array($this, 'display'), $added); 1667 $deleted = array_map(array($this, 'display'), $deleted); 1668 1669 if ($added && $deleted) { 1670 $desc = sprintf( 1671 __('added <strong>%1$s</strong> and removed <strong>%2$s</strong>'), 1672 implode(', ', $added), implode(', ', $deleted)); 1673 } 1674 elseif ($added) { 1675 $desc = sprintf( 1676 __('added <strong>%1$s</strong>'), 1677 implode(', ', $added)); 1678 } 1679 elseif ($deleted) { 1680 $desc = sprintf( 1681 __('removed <strong>%1$s</strong>'), 1682 implode(', ', $deleted)); 1683 } 1684 else { 1685 $desc = sprintf( 1686 __('changed to <strong>%1$s</strong>'), 1687 $this->display($after)); 1688 } 1689 return $desc; 1690 } 1691 1692 function asVar($value, $id=false) { 1693 $values = $this->to_php($value, $id); 1694 if (is_array($values)) { 1695 return new PlaceholderList($this->getList()->getAllItems() 1696 ->filter(array('id__in' => array_keys($values))) 1697 ); 1698 } 1699 } 1700 1701 function hasSubFields() { 1702 return $this->getList()->getForm(); 1703 } 1704 function getSubFields() { 1705 $fields = new ListObject(array( 1706 new TextboxField(array( 1707 // XXX: i18n: Change to a better word when the UI changes 1708 'label' => '['.__('Abbrev').']', 1709 'id' => 'abb', 1710 )) 1711 )); 1712 $form = $this->getList()->getForm(); 1713 if ($form && ($F = $form->getFields())) 1714 $fields->extend($F); 1715 return $fields; 1716 } 1717 1718 function toString($items) { 1719 return is_array($items) 1720 ? implode(', ', $items) : (string) $items; 1721 } 1722 1723 function validateEntry($entry) { 1724 parent::validateEntry($entry); 1725 if (!$this->errors()) { 1726 $config = $this->getConfiguration(); 1727 if ($config['widget'] == 'textbox') { 1728 if ($entry && ( 1729 !($k=key($entry)) 1730 || !($i=$this->getList()->getItem((int) $k)) 1731 )) { 1732 $config = $this->getConfiguration(); 1733 $this->_errors[] = $this->getLocal('validator-error', $config['validator-error']) 1734 ?: __('Unknown or invalid input'); 1735 } 1736 } elseif ($config['typeahead'] 1737 && ($entered = $this->getWidget()->getEnteredValue()) 1738 && !in_array($entered, $entry) 1739 && $entered != $entry) { 1740 $this->_errors[] = __('Select a value from the list'); 1741 } 1742 } 1743 } 1744 1745 function getConfigurationOptions() { 1746 return array( 1747 'multiselect' => new BooleanField(array( 1748 'id'=>2, 1749 'label'=>__(/* Type of widget allowing multiple selections */ 'Multiselect'), 1750 'required'=>false, 'default'=>false, 1751 'configuration'=>array( 1752 'desc'=>__('Allow multiple selections')), 1753 )), 1754 'widget' => new ChoiceField(array( 1755 'id'=>1, 1756 'label'=>__('Widget'), 1757 'required'=>false, 'default' => 'dropdown', 1758 'choices'=>array( 1759 'dropdown' => __('Drop Down'), 1760 'typeahead' => __('Typeahead'), 1761 'textbox' => __('Text Input'), 1762 ), 1763 'configuration'=>array( 1764 'multiselect' => false, 1765 ), 1766 'visibility' => new VisibilityConstraint( 1767 new Q(array('multiselect__eq'=>false)), 1768 VisibilityConstraint::HIDDEN 1769 ), 1770 'hint'=>__('Typeahead will work better for large lists') 1771 )), 1772 'validator-error' => new TextboxField(array( 1773 'id'=>5, 'label'=>__('Validation Error'), 'default'=>'', 1774 'configuration'=>array('size'=>40, 'length'=>80, 1775 'translatable'=>$this->getTranslateTag('validator-error') 1776 ), 1777 'visibility' => new VisibilityConstraint( 1778 new Q(array('widget__eq'=>'textbox')), 1779 VisibilityConstraint::HIDDEN 1780 ), 1781 'hint'=>__('Message shown to user if the item entered is not in the list') 1782 )), 1783 'prompt' => new TextboxField(array( 1784 'id'=>3, 1785 'label'=>__('Prompt'), 'required'=>false, 'default'=>'', 1786 'hint'=>__('Leading text shown before a value is selected'), 1787 'configuration'=>array('size'=>40, 'length'=>40, 1788 'translatable'=>$this->getTranslateTag('prompt'), 1789 ), 1790 )), 1791 'default' => new SelectionField(array( 1792 'id'=>4, 'label'=>__('Default'), 'required'=>false, 'default'=>'', 1793 'list_id'=>$this->getListId(), 1794 'configuration' => array('prompt'=>__('Select a Default')), 1795 )), 1796 ); 1797 } 1798 1799 function getConfiguration() { 1800 1801 $config = parent::getConfiguration(); 1802 if ($config['widget']) 1803 $config['typeahead'] = $config['widget'] == 'typeahead'; 1804 1805 // Drop down list does not support multiple selections 1806 if ($config['typeahead']) 1807 $config['multiselect'] = false; 1808 1809 return $config; 1810 } 1811 1812 function getChoices($verbose=false, $options=array()) { 1813 if (!$this->_choices || $verbose) { 1814 $choices = array(); 1815 foreach ($this->getList()->getItems() as $i) 1816 $choices[$i->getId()] = $i->getValue(); 1817 1818 // Retired old selections 1819 $values = ($a=$this->getAnswer()) ? $a->getValue() : array(); 1820 if ($values && is_array($values)) { 1821 foreach ($values as $k => $v) { 1822 if (!isset($choices[$k])) { 1823 if ($verbose) $v .= ' '.__('(retired)'); 1824 $choices[$k] = $v; 1825 } 1826 } 1827 } 1828 1829 if ($verbose) // Don't cache 1830 return $choices; 1831 1832 $this->_choices = $choices; 1833 } 1834 1835 return $this->_choices; 1836 } 1837 1838 function getChoice($value) { 1839 $choices = $this->getChoices(); 1840 if ($value && is_array($value)) { 1841 $selection = $value; 1842 } elseif (isset($choices[$value])) 1843 $selection[] = $choices[$value]; 1844 elseif ($this->get('default')) 1845 $selection[] = $choices[$this->get('default')]; 1846 1847 return $selection; 1848 } 1849 1850 function lookupChoice($value) { 1851 1852 // See if it's in the choices. 1853 $choices = $this->getChoices(); 1854 if ($choices && ($i=array_search($value, $choices))) 1855 return array($i=>$choices[$i]); 1856 1857 // Query the store by value or extra (abbrv.) 1858 if (!($list=$this->getList())) 1859 return null; 1860 1861 if ($i = $list->getItem($value)) 1862 return array($i->getId() => $i->getValue()); 1863 1864 if ($i = $list->getItem($value, true)) 1865 return array($i->getId() => $i->getValue()); 1866 1867 return null; 1868 } 1869 1870 1871 function getFilterData() { 1872 // Start with the filter data for the list item as the [0] index 1873 $data = array(parent::getFilterData()); 1874 if (($v = $this->getClean())) { 1875 // Add in the properties for all selected list items in sub 1876 // labeled by their field id 1877 foreach ($v as $id=>$L) { 1878 if (!($li = DynamicListItem::lookup($id)) 1879 || !$li->getListId()) 1880 continue; 1881 foreach ($li->getFilterData() as $prop=>$value) { 1882 if (!isset($data[$prop])) 1883 $data[$prop] = $value; 1884 else 1885 $data[$prop] .= " $value"; 1886 } 1887 } 1888 } 1889 return $data; 1890 } 1891 1892 function getSearchMethods() { 1893 return array( 1894 'set' => __('has a value'), 1895 'nset' => __('does not have a value'), 1896 'includes' => __('includes'), 1897 '!includes' => __('does not include'), 1898 ); 1899 } 1900 1901 function getSearchMethodWidgets() { 1902 return array( 1903 'set' => null, 1904 'nset' => null, 1905 'includes' => array('ChoiceField', array( 1906 'choices' => $this->getChoices(), 1907 'configuration' => array('multiselect' => true), 1908 )), 1909 '!includes' => array('ChoiceField', array( 1910 'choices' => $this->getChoices(), 1911 'configuration' => array('multiselect' => true), 1912 )), 1913 ); 1914 } 1915 1916 function getSearchQ($method, $value, $name=false) { 1917 $name = $name ?: $this->get('name'); 1918 $val = $value; 1919 if ($value && is_array($value)) 1920 $val = '"?'.implode('("|,|$)|"?', array_keys($value)).'("|,|$)'; 1921 switch ($method) { 1922 case '!includes': 1923 return Q::not(array("{$name}__regex" => $val)); 1924 case 'includes': 1925 return new Q(array("{$name}__regex" => $val)); 1926 default: 1927 return parent::getSearchQ($method, $value, $name); 1928 } 1929 } 1930} 1931 1932class TypeaheadSelectionWidget extends ChoicesWidget { 1933 function render($options=array()) { 1934 1935 if ($options['mode'] == 'search') 1936 return parent::render($options); 1937 1938 $name = $this->getEnteredValue(); 1939 $config = $this->field->getConfiguration(); 1940 if (is_array($this->value)) { 1941 $name = $name ?: current($this->value); 1942 $value = key($this->value); 1943 } 1944 else { 1945 // Pull configured default (if configured) 1946 $def_key = $this->field->get('default'); 1947 if (!$def_key && $config['default']) 1948 $def_key = $config['default']; 1949 if (is_array($def_key)) 1950 $name = current($def_key); 1951 } 1952 1953 $source = array(); 1954 foreach ($this->field->getList()->getItems() as $i) 1955 $source[] = array( 1956 'value' => $i->getValue(), 'id' => $i->getId(), 1957 'info' => sprintf('%s%s', 1958 $i->getValue(), 1959 (($extra= $i->getAbbrev()) ? " — $extra" : '')), 1960 ); 1961 ?> 1962 <span style="display:inline-block"> 1963 <input type="text" size="30" name="<?php echo $this->name; ?>_name" 1964 id="<?php echo $this->name; ?>" value="<?php echo Format::htmlchars($name); ?>" 1965 placeholder="<?php echo $config['prompt']; 1966 ?>" autocomplete="off" /> 1967 <input type="hidden" name="<?php echo $this->name; 1968 ?>_id" id="<?php echo $this->name; 1969 ?>_id" value="<?php echo Format::htmlchars($value); ?>"/> 1970 <script type="text/javascript"> 1971 $(function() { 1972 $('input#<?php echo $this->name; ?>').typeahead({ 1973 source: <?php echo JsonDataEncoder::encode($source); ?>, 1974 property: 'info', 1975 onselect: function(item) { 1976 $('input#<?php echo $this->name; ?>_name').val(item['value']) 1977 $('input#<?php echo $this->name; ?>_id') 1978 .attr('name', '<?php echo $this->name; ?>[' + item['id'] + ']') 1979 .val(item['value']); 1980 return false; 1981 } 1982 }); 1983 }); 1984 </script> 1985 </span> 1986 <?php 1987 } 1988 1989 function parsedValue() { 1990 return array($this->getValue() => $this->getEnteredValue()); 1991 } 1992 1993 function getValue() { 1994 $data = $this->field->getSource(); 1995 $name = $this->field->get('name'); 1996 if (isset($data["{$this->name}_id"]) && is_numeric($data["{$this->name}_id"])) { 1997 return array($data["{$this->name}_id"] => $data["{$this->name}_name"]); 1998 } 1999 elseif (isset($data[$name])) { 2000 return $data[$name]; 2001 } 2002 // Attempt to lookup typed value (usually from a default) 2003 elseif ($val = $this->getEnteredValue()) { 2004 return $this->field->lookupChoice($val); 2005 } 2006 2007 return parent::getValue(); 2008 } 2009 2010 function getEnteredValue() { 2011 // Used to verify typeahead fields 2012 $data = $this->field->getSource(); 2013 if (isset($data[$this->name.'_name'])) { 2014 // Drop the extra part, if any 2015 $v = $data[$this->name.'_name']; 2016 $pos = strrpos($v, ' — '); 2017 if ($pos !== false) 2018 $v = substr($v, 0, $pos); 2019 2020 return trim($v); 2021 } 2022 return parent::getValue(); 2023 } 2024} 2025?> 2026