1 <?php
2 /**
3 * WhEditable class
4 *
5 * Creates editable element on page (without linking to model attribute)
6 *
7 * @author Antonio Ramirez <amigo.cobos@gmail.com>
8 * @copyright Copyright © 2amigos.us 2013-
9 * @license http://www.opensource.org/licenses/bsd-license.php New BSD License
10 * @package YiiWheels.widgets.editable
11 *
12 * @author Vitaliy Potapov <noginsk@rambler.ru>
13 * @link https://github.com/vitalets/x-editable-yii
14 * @copyright Copyright © Vitaliy Potapov 2012
15 * @version 1.3.1
16 */
17 class WhEditable extends CWidget
18 {
19
20 /**
21 * @var string type of editable widget. Can be `text`, `textarea`, `select`, `date`, `checklist`, etc.
22 * @see x-editable
23 */
24 public $type = null;
25
26 /**
27 * @var string url to submit value. Can be string or array containing Yii route, e.g. `array('site/updateUser')`
28 * @see x-editable
29 */
30 public $url = null;
31
32 /**
33 * @var mixed primary key
34 * @see x-editable
35 */
36 public $pk = null;
37
38 /**
39 * @var string name of field
40 * @see x-editable
41 */
42 public $name = null;
43
44 /**
45 * @var array additional params to send on server
46 * @see x-editable
47 */
48 public $params = null;
49 /**
50 * @var string css class of input. If `null` - default X-editable value is used: `input-medium`
51 * @see x-editable
52 */
53 public $inputclass = null;
54
55 /**
56 * @var string mode of input: `inline` | `popup`. If not set - default X-editable value is used: `popup`.
57 * @see x-editable
58 */
59 public $mode = null;
60
61 /**
62 * @var string text to be shown as element content
63 */
64 public $text = null;
65
66 /**
67 * @var mixed initial value. If not set - will be taken from text
68 * @see x-editable
69 */
70 public $value = null;
71
72 /**
73 * @var string placement of popup. Can be `left`, `top`, `right`, `bottom`. If `null` - default X-editable value is used: `top`
74 * @see x-editable
75 */
76 public $placement = null;
77
78 /**
79 * @var string text shown on empty field. If `null` - default X-editable value is used: `Empty`
80 * @see x-editable
81 */
82 public $emptytext = null;
83
84 /**
85 * @var string visibility of buttons. Can be boolean `false|true` or string `bottom`.
86 * @see x-editable
87 */
88 public $showbuttons = null;
89
90 /**
91 * @var string Strategy for sending data on server. Can be `auto|always|never`.
92 * When 'auto' data will be sent on server only if **pk** and **url** defined, otherwise new value will be stored locally.
93 * @see x-editable
94 */
95 public $send = null;
96
97 /**
98 * @var boolean will editable be initially disabled. It means editable plugin will be applied to element,
99 * but you should call `.editable('enable')` method to activate it.
100 * To totally disable applying 'editable' to element use **apply** option.
101 * @see x-editable
102 */
103 public $disabled = false;
104
105 //list
106 /**
107 * @var mixed source data for **select**, **checklist**. Can be string (url) or array in format:
108 * array( array("value" => 1, "text" => "abc"), ...)
109 * @package list
110 * @see x-editable
111 */
112 public $source = null;
113
114 //date
115 /**
116 * @var string format to send date on server. If `null` - default X-editable value is used: `yyyy-mm-dd`.
117 * @package date
118 * @see x-editable
119 */
120 public $format = null;
121
122 /**
123 * @var string format to display date in element. If `null` - equals to **format** option.
124 * @package date
125 * @see x-editable
126 */
127 public $viewformat = null;
128
129 /**
130 * @var string template for **combodate** input. For details see http://vitalets.github.com/x-editable/docs.html#combodate.
131 * @package combodate
132 * @see x-editable
133 */
134 public $template = null;
135
136 /**
137 * @var array full config for **combodate** input. For details see http://vitalets.github.com/combodate/#docs
138 * @package combodate
139 * @see x-editable
140 */
141 public $combodate = null;
142
143 /**
144 * @var string separator used to display tags.
145 * @package select2
146 * @see x-editable
147 */
148 public $viewseparator = null;
149
150 /**
151 * @var array full config for **select2** input. For details see http://ivaynberg.github.com/select2
152 * @package select2
153 * @see x-editable
154 */
155 public $select2 = null;
156
157 //methods
158 /**
159 * A javascript function that will be invoked to validate value.
160 * Example:
161 * <pre>
162 * 'validate' => 'js: function(value) {
163 * if($.trim(value) == "") return "This field is required";
164 * }'
165 * </pre>
166 *
167 * @var string
168 * @package callback
169 * @see x-editable
170 * @example
171 */
172 public $validate = null;
173
174 /**
175 * A javascript function that will be invoked to process successful server response.
176 * Example:
177 * <pre>
178 * 'success' => 'js: function(response, newValue) {
179 * if(!response.success) return response.msg;
180 * }'
181 * </pre>
182 *
183 * @var string
184 * @package callback
185 * @see x-editable
186 */
187 public $success = null;
188
189 /**
190 * A javascript function that will be invoked to custom display value.
191 * Example:
192 * <pre>
193 * 'display' => 'js: function(value, sourceData) {
194 * var escapedValue = $("<div>").text(value).html();
195 * $(this).html("<b>"+escapedValue+"</b>");
196 * }'
197 * </pre>
198 *
199 * @var string
200 * @package callback
201 * @see x-editable
202 */
203 public $display = null;
204
205 /**
206 * DOM id of target where afterAjaxUpdate handler will call
207 * live update of editable element
208 *
209 * @var string
210 */
211 public $liveTarget = null;
212 /**
213 * jQuery selector of elements to wich will be applied editable.
214 * Usefull in combination of `liveTarget` when you want to keep field(s) editble
215 * after ajaxUpdate
216 *
217 * @var string
218 */
219 public $liveSelector = null;
220
221 // --- X-editable events ---
222 /**
223 * A javascript function that will be invoked when editable element is initialized
224 * @var string
225 * @package event
226 * @see x-editable
227 */
228 public $onInit;
229 /**
230 * A javascript function that will be invoked when editable form is shown
231 * Example:
232 * <pre>
233 * 'onShown' => 'js: function() {
234 * var $tip = $(this).data("editableContainer").tip();
235 * $tip.find("input").val("overwriting value of input.");
236 * }'
237 * </pre>
238 *
239 * @var string
240 * @package event
241 * @see x-editable
242 */
243 public $onShown;
244 /**
245 * A javascript function that will be invoked when new value is saved
246 * Example:
247 * <pre>
248 * 'onSave' => 'js: function(e, params) {
249 * alert("Saved value: " + params.newValue);
250 * }'
251 * </pre>
252 *
253 * @var string
254 * @package event
255 * @see x-editable
256 */
257 public $onSave;
258 /**
259 * A javascript function that will be invoked when editable form is hidden
260 * Example:
261 * <pre>
262 * 'onHidden' => 'js: function(e, reason) {
263 * if(reason === "save" || reason === "cancel") {
264 * //auto-open next editable
265 * $(this).closest("tr").next().find(".editable").editable("show");
266 * }
267 * }'
268 * </pre>
269 *
270 * @var string
271 * @package event
272 * @see x-editable
273 */
274 public $onHidden;
275
276 /**
277 * @var array all config options of x-editable. See full list <a href="http://vitalets.github.com/x-editable/docs.html#editable">here</a>.
278 */
279 public $options = array();
280
281 /**
282 * @var array HTML options of element. In `EditableColumn` htmlOptions are PHP expressions
283 * so you can use `$data` to bind values to particular cell, e.g. `'data-categoryID' => '$data->categoryID'`.
284 */
285 public $htmlOptions = array();
286
287 /**
288 * @var boolean whether to HTML encode text on output
289 */
290 public $encode = true;
291
292 /**
293 * @var boolean whether to apply 'editable' js plugin to element.
294 * Only **safe** attributes become editable.
295 */
296 public $apply = null;
297
298 /**
299 * @var string title of popup. If `null` - will be generated automatically from attribute label.
300 * Can have token {label} inside that will be replaced with actual attribute label.
301 */
302 public $title = null;
303
304 /**
305 * @var string for jQuery UI only. The JUI theme name.
306 */
307 public $theme = 'base';
308
309
310 protected $_prepareToAutotext = false;
311
312 /**
313 * initialization of widget
314 *
315 */
316 public function init()
317 {
318 parent::init();
319
320 if (!$this->name) {
321 throw new CException('Parameter "name" should be provided for Editable widget');
322 }
323
324 $this->attachBehavior('ywplugin', array('class' => 'yiiwheels.behaviors.WhPlugin'));
325 $this->_prepareToAutotext = self::isAutotext($this->options, $this->type);
326 }
327
328 /**
329 * Builds html options
330 */
331 public function buildHtmlOptions()
332 {
333 //html options
334 $htmlOptions = array(
335 'href' => '#',
336 'rel' => $this->liveSelector ? $this->liveSelector : $this->getSelector(),
337 );
338
339 //set data-pk
340 if ($this->pk !== null) {
341 $htmlOptions['data-pk'] = is_array($this->pk) ? CJSON::encode($this->pk) : $this->pk;
342 }
343
344 //if input type assumes autotext (e.g. select) we define value directly in data-value
345 //and do not fill element contents
346 if ($this->_prepareToAutotext) {
347 //for date we use 'format' to put it into value (if text not defined)
348 if ($this->type == 'date') {
349 //if date comes as object, format it to string
350 if ($this->value instanceOf DateTime || is_long($this->value)) {
351 /*
352 * unfortunatly datepicker's format does not match Yii locale dateFormat,
353 * we need replacements below to convert date correctly
354 */
355 $count = 0;
356 $format = str_replace('MM', 'MMMM', $this->format, $count);
357 if (!$count) $format = str_replace('M', 'MMM', $format, $count);
358 if (!$count) $format = str_replace('m', 'M', $format);
359
360 if ($this->value instanceof DateTime) {
361 $timestamp = $this->value->getTimestamp();
362 } else {
363 $timestamp = $this->value;
364 }
365
366 $this->value = Yii::app()->dateFormatter->format($format, $timestamp);
367 }
368 }
369
370 if (is_scalar($this->value)) {
371 $this->htmlOptions['data-value'] = $this->value;
372 }
373 //if not scalar, value will be added to js options instead of html options
374 }
375
376 //merging options
377 $this->htmlOptions = CMap::mergeArray($this->htmlOptions, $htmlOptions);
378
379 //convert arrays to json string, otherwise yii can not render it:
380 //"htmlspecialchars() expects parameter 1 to be string, array given"
381 foreach ($this->htmlOptions as $k => $v) {
382 $this->htmlOptions[$k] = is_array($v) ? CJSON::encode($v) : $v;
383 }
384 }
385
386 /**
387 * Builds javascript options
388 */
389 public function buildJsOptions()
390 {
391 //normalize url from array
392 $this->url = CHtml::normalizeUrl($this->url);
393
394 $options = array(
395 'name' => $this->name,
396 'title' => CHtml::encode($this->title),
397 );
398
399 //if value needed for autotext and it's not scalar --> add it to js options
400 if ($this->_prepareToAutotext && !is_scalar($this->value)) {
401 $options['value'] = $this->value;
402 }
403
404 //support of CSRF out of box, see https://github.com/vitalets/x-editable-yii/issues/38
405 if (Yii::app()->request->enableCsrfValidation) {
406 $csrfTokenName = Yii::app()->request->csrfTokenName;
407 $csrfToken = Yii::app()->request->csrfToken;
408 if (!isset($this->params[$csrfTokenName])) {
409 $this->params[$csrfTokenName] = $csrfToken;
410 }
411 }
412
413 //simple options set directly from config
414 foreach (array(
415 'url',
416 'type',
417 'mode',
418 'placement',
419 'emptytext',
420 'params',
421 'inputclass',
422 'format',
423 'viewformat',
424 'template',
425 'combodate',
426 'select2',
427 'viewseparator',
428 'showbuttons',
429 'send',
430 ) as $option) {
431 if ($this->$option !== null) {
432 $options[$option] = $this->$option;
433 }
434 }
435
436 if ($this->source) {
437
438 //if source is array --> convert it to x-editable format.
439 //Since 1.1.0 source as array with one element is NOT treated as Yii route!
440 if (is_array($this->source)) {
441 //if first elem is array assume it's normal x-editable format, so just pass it
442 if (isset($this->source[0]) && is_array($this->source[0])) {
443 $options['source'] = $this->source;
444 } else { //else convert to x-editable source format {value: 1, text: 'abc'}
445 $options['source'] = array();
446 foreach ($this->source as $value => $text) {
447 $options['source'][] = array('value' => $value, 'text' => $text);
448 }
449 }
450 } else { //source is url string (or js function)
451 $options['source'] = CHtml::normalizeUrl($this->source);
452 }
453 }
454
455 //callbacks
456 foreach (array('validate', 'success', 'display') as $method) {
457 if (isset($this->$method)) {
458 $options[$method] = (strpos($this->$method, 'js:') !== 0 ? 'js:' : '') . $this->$method;
459 }
460 }
461
462 //merging options
463 $this->options = CMap::mergeArray($this->options, $options);
464
465 //i18n for `clear` in date and datetime
466 if ($this->type == 'date' || $this->type == 'datetime') {
467 if (!isset($this->options['clear'])) {
468 $this->options['clear'] = Yii::t('EditableField.editable', 'x clear');
469 }
470 }
471 }
472
473 /**
474 * Registers client script
475 * @return string
476 */
477 public function registerClientScript()
478 {
479 $selector = "a[rel=\"{$this->htmlOptions['rel']}\"]";
480 if ($this->liveTarget) {
481 $selector = '#' . $this->liveTarget . ' ' . $selector;
482 }
483 $script = "$('" . $selector . "')";
484
485 //attach events
486 foreach (array('init', 'shown', 'save', 'hidden') as $event) {
487 $eventName = 'on' . ucfirst($event);
488 if (isset($this->$eventName)) {
489 // CJavaScriptExpression appeared only in 1.1.11, will turn to it later
490 //$event = ($this->onInit instanceof CJavaScriptExpression) ? $this->onInit : new CJavaScriptExpression($this->onInit);
491 $eventJs = (strpos($this->$eventName, 'js:') !== 0 ? 'js:' : '') . $this->$eventName;
492 $script .= "\n.on('" . $event . "', " . CJavaScript::encode($eventJs) . ")";
493 }
494 }
495
496 //apply editable
497 $options = CJavaScript::encode($this->options);
498 $script .= ".editable($options);";
499
500 //wrap in anonymous function for live update
501 if ($this->liveTarget) {
502 $script .= "\n $('body').on('ajaxUpdate.editable', function(e){ if(e.target.id == '" . $this->liveTarget . "'){yiiEditable();}});";
503 $script = "(function yiiEditable() {\n " . $script . "\n}());";
504 }
505
506 Yii::app()->getClientScript()->registerScript(__CLASS__ . '-' . $selector, $script);
507
508 return $script;
509 }
510
511 /**
512 * Registers assets
513 * @throws CException
514 */
515 public function registerAssets()
516 {
517 $cs = Yii::app()->getClientScript();
518
519
520 $path = __DIR__ . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'bootstrap-editable';
521 $assetsUrl = $this->getAssetsUrl($path);
522
523 //register assets
524 $cs->registerCssFile($assetsUrl . '/css/bootstrap-editable.css');
525 $cs->registerScriptFile($assetsUrl . '/js/bootstrap-editable.js', CClientScript::POS_END);
526
527 //include moment.js for combodate
528 if ($this->type == 'combodate') {
529 $path = __DIR__ . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'moment';
530 $momentUrl = Yii::app()->assetManager->publish($path, false, -1, $this->getApi()->forceCopyAssets);
531 $cs->registerScriptFile($momentUrl . '/moment.min.js');
532 }
533
534 //include select2 lib for select2 type
535 if ($this->type == 'select2') {
536 $path = __DIR__ . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'select2';
537 $select2Url = Yii::app()->assetManager->publish($path, false, -1, $this->getApi()->forceCopyAssets);
538 $cs->registerScriptFile($select2Url . '/select2.min.js');
539 $cs->registerCssFile($select2Url . '/select2.css');
540 }
541
542 //include bootstrap-datetimepicker
543 if ($this->type == 'datetime') {
544 $path = __DIR__ . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'bootstrap-datetimepicker';
545 $dateTimePickerUrl = Yii::app()->assetManager->publish($path, false, -1, $this->getApi()->forceCopyAssets);
546 $cs->registerScriptFile($dateTimePickerUrl . '/js/bootstrap-datetimepicker.js');
547 $cs->registerCssFile($dateTimePickerUrl . '/css/datetimepicker.css');
548 }
549 }
550
551 /**
552 * Widget run method
553 */
554 public function run()
555 {
556 //Register script (even if apply = false to support live update)
557 if ($this->apply !== false || $this->liveTarget) {
558 $this->buildHtmlOptions();
559 $this->buildJsOptions();
560 $this->registerAssets();
561 $this->registerClientScript();
562 }
563
564 if ($this->apply !== false) {
565 $this->renderLink();
566 } else {
567 $this->renderText();
568 }
569 }
570
571 /**
572 * Renders a link
573 */
574 public function renderLink()
575 {
576 echo CHtml::openTag('a', $this->htmlOptions);
577 $this->renderText();
578 echo CHtml::closeTag('a');
579 }
580
581 /**
582 * Renders text
583 */
584 public function renderText()
585 {
586 $encodedText = $this->encode ? CHtml::encode($this->text) : $this->text;
587 if ($this->type == 'textarea') {
588 $encodedText = preg_replace('/\r?\n/', '<br>', $encodedText);
589 }
590 echo $encodedText;
591 }
592
593 /**
594 * Returns the plugin selector
595 * @return null|string
596 */
597 public function getSelector()
598 {
599 //for live updates selector should not contain pk
600 if ($this->liveTarget) {
601 return $this->name;
602 }
603
604 $pk = $this->pk;
605 if ($pk === null) {
606 $pk = 'new';
607 } else {
608 //support of composite keys: convert to string: e.g. 'id-1_lang-ru'
609 if (is_array($pk)) {
610 //below not works in PHP < 5.3, see https://github.com/vitalets/x-editable-yii/issues/39
611 //$pk = join('_', array_map(function($k, $v) { return $k.'-'.$v; }, array_keys($pk), $pk));
612 $buffer = array();
613 foreach ($pk as $k => $v) {
614 $buffer[] = $k . '-' . $v;
615 }
616 $pk = join('_', $buffer);
617 }
618 }
619
620
621 return $this->name . '_' . $pk;
622 }
623
624 /**
625 * Returns is autotext should be applied to widget:
626 * e.g. for 'select' to display text for id
627 *
628 * @param mixed $options
629 * @param mixed $type
630 * @return bool
631 */
632 public static function isAutotext($options, $type)
633 {
634 return (!isset($options['autotext']) || $options['autotext'] !== 'never')
635 && in_array($type, array(
636 'select',
637 'checklist',
638 'date',
639 'datetime',
640 'dateui',
641 'combodate',
642 'select2'
643 ));
644 }
645
646 /**
647 * Returns php-array as valid x-editable source in format:
648 * [{value: 1, text: 'text1'}, {...}]
649 *
650 * See https://github.com/vitalets/x-editable-yii/issues/37
651 *
652 * @param mixed $models
653 * @param mixed $valueField
654 * @param mixed $textField
655 * @param mixed $groupField
656 * @param mixed $groupTextField
657 * @return array
658 */
659 public static function source($models, $valueField = '', $textField = '', $groupField = '', $groupTextField = '')
660 {
661 $listData = array();
662
663 $first = reset($models);
664
665 //simple 1-dimensional array: 0 => 'text 0', 1 => 'text 1'
666 if ($first && (is_string($first) || is_numeric($first))) {
667 foreach ($models as $key => $text) {
668 $listData[] = array('value' => $key, 'text' => $text);
669 }
670 return $listData;
671 }
672
673 // 2-dimensional array or dataset
674 if ($groupField === '') {
675 foreach ($models as $model) {
676 $value = CHtml::value($model, $valueField);
677 $text = CHtml::value($model, $textField);
678 $listData[] = array('value' => $value, 'text' => $text);
679 }
680 } else {
681 if (!$groupTextField) {
682 $groupTextField = $groupField;
683 }
684 $groups = array();
685 foreach ($models as $model) {
686 $group = CHtml::value($model, $groupField);
687 $groupText = CHtml::value($model, $groupTextField);
688 $value = CHtml::value($model, $valueField);
689 $text = CHtml::value($model, $textField);
690 if ($group === null) {
691 $listData[] = array('value' => $value, 'text' => $text);
692 } else {
693 if (!isset($groups[$group])) {
694 $groups[$group] = array(
695 'value' => $group,
696 'text' => $groupText,
697 'children' => array(),
698 'index' => count($listData)
699 );
700 $listData[] = 'group'; //placeholder, will be replaced in future
701 }
702 $groups[$group]['children'][] = array('value' => $value, 'text' => $text);
703 }
704 }
705
706 //fill placeholders with group data
707 foreach ($groups as $group) {
708 $index = $group['index'];
709 unset($group['index']);
710 $listData[$index] = $group;
711 }
712 }
713
714 return $listData;
715 }
716
717 /**
718 * injects ajaxUpdate event into widget
719 *
720 * @param mixed $widget
721 */
722 public static function attachAjaxUpdateEvent($widget)
723 {
724 $trigger = '$("#' . $widget->id . '").trigger("ajaxUpdate.editable");';
725
726 //check if trigger already inserted by another column
727 if (strpos($widget->afterAjaxUpdate, $trigger) !== false) return;
728
729 //inserting trigger
730 if (strlen($widget->afterAjaxUpdate)) {
731 $orig = $widget->afterAjaxUpdate;
732 if (strpos($orig, 'js:') === 0) $orig = substr($orig, 3);
733 $orig = "\n($orig).apply(this, arguments);";
734 } else {
735 $orig = '';
736 }
737 $widget->afterAjaxUpdate = "js: function(id, data) {
738 $trigger $orig
739 }";
740
741 $widget->registerClientScript();
742 }
743
744 }
745