1 <?php
2
3 /**
4 * Abstract base class containing core functionality for Fieldmanager fields.
5 *
6 * Fields are UI elements that allow a person to interact with data.
7 *
8 * @package Fieldmanager_Field
9 */
10 abstract class Fieldmanager_Field {
11
12 /**
13 * @var boolean
14 * If true, throw exceptions for illegal behavior
15 */
16 public static $debug = FM_DEBUG;
17
18 /**
19 * @var int
20 * How many of these fields to display, 0 for no limit
21 */
22 public $limit = 1;
23
24 /**
25 * DEPREATED: How many of these fields to display initially, if $limit != 1.
26 * @deprecated This argument will have no impact. It only remains to avoid
27 * throwing exceptions in code that used it previously.
28 * @var int
29 */
30 public $starting_count = 1;
31
32 /**
33 * How many of these fields to display at a minimum, if $limit != 1. If
34 * $limit == $minimum_count, the "add another" button and the remove tool
35 * will be hidden.
36 * @var int
37 */
38 public $minimum_count = 0;
39
40 /**
41 * @var int
42 * How many empty fields to display if $limit != 1 when the total fields in
43 * the loaded data + $extra_elements > $minimum_count.
44 */
45 public $extra_elements = 1;
46
47 /**
48 * @var string
49 * Text for add more button
50 */
51 public $add_more_label = '';
52
53 /**
54 * @var string
55 * The name of the form element, As in 'foo' in <input name="foo" />
56 */
57 public $name = '';
58
59 /**
60 * @var string
61 * Label to use for form element
62 */
63 public $label = '';
64
65 /**
66 * @var boolean
67 * If true, the label and the element will display on the same line. Some elements may not support this.
68 */
69 public $inline_label = False;
70
71 /**
72 * @var boolean
73 * If true, the label will be displayed after the element.
74 */
75 public $label_after_element = False;
76
77 /**
78 * @var string
79 * Description for the form element
80 */
81 public $description = '';
82
83 /**
84 * @var boolean
85 * If true, the description will be displayed after the element.
86 */
87 public $description_after_element = true;
88
89 /**
90 * @var string|boolean[]
91 * Extra HTML attributes to apply to the form element. Use boolean true to apply a standalone attribute, e.g. 'required' => true
92 */
93 public $attributes = array();
94
95 /**
96 * @var string
97 * CSS class for form element
98 */
99 public $field_class = 'element';
100
101 /**
102 * @var boolean
103 * Repeat the label for each element if $limit > 1
104 */
105 public $one_label_per_item = TRUE;
106
107 /**
108 * @var boolean
109 * Allow draggable sorting if $limit > 1
110 */
111 public $sortable = FALSE;
112
113 /**
114 * @var string
115 * HTML element to use for label
116 */
117 public $label_element = 'div';
118
119 /**
120 * @var callback
121 * Function to use to sanitize input
122 */
123 public $sanitize = 'sanitize_text_field';
124
125 /**
126 * @var callback[]
127 * Functions to use to validate input
128 */
129 public $validate = array();
130
131 /**
132 * @var string|array
133 * jQuery validation rule(s) used to validate this field, entered as a string or associative array.
134 * These rules will be automatically converted to the appropriate Javascript format.
135 * For more information see http://jqueryvalidation.org/documentation/
136 */
137 public $validation_rules;
138
139 /**
140 * @var string|array
141 * jQuery validation messages used by the rule(s) defined for this field, entered as a string or associative array.
142 * These rules will be automatically converted to the appropriate Javascript format.
143 * Any messages without a corresponding rule will be ignored.
144 * For more information see http://jqueryvalidation.org/documentation/
145 */
146 public $validation_messages;
147
148 /**
149 * @var boolean
150 * Makes the field required on WordPress context forms that already have built-in validation.
151 * This is necessary only for the fields used with the term add context.
152 */
153 public $required = false;
154
155 /**
156 * @var string|null
157 * Data type this element is used in, generally set internally
158 */
159 public $data_type = NULL;
160
161 /**
162 * @var int|null
163 * ID for $this->data_type, eg $post->ID, generally set internally
164 */
165 public $data_id = Null;
166
167 /**
168 * @var boolean
169 * If true, save empty elements to DB (if $this->limit != 1; single elements are always saved)
170 */
171 public $save_empty = False;
172
173 /**
174 * @var boolean
175 * Do not save this field (useful for fields which handle saving their own data)
176 */
177 public $skip_save = False;
178
179 /**
180 * Save this field additionally to an index
181 * @var boolean
182 */
183 public $index = False;
184
185 /**
186 * Save the fields to their own keys (only works in some contexts). Default
187 * is true.
188 * @var boolean
189 */
190 public $serialize_data = true;
191
192 /**
193 * @var Fieldmanager_Datasource
194 * Optionally generate field from datasource. Used by Fieldmanager_Autocomplete and Fieldmanager_Options.
195 */
196 public $datasource = Null;
197
198 /**
199 * @var array[]
200 * Field name and value on which to display element. Sample:
201 * $element->display_if = array(
202 * 'src' => 'display-if-src-element',
203 * 'value' => 'display-if-src-value'
204 * );
205 */
206 public $display_if = array();
207
208 /**
209 * @var string
210 * Where the new item should to added ( top/bottom ) of the stack. Used by Add Another button
211 * "top|bottom"
212 */
213 public $add_more_position = "bottom";
214
215 /**
216 * @var boolean
217 * If true, remove any default meta boxes that are overridden by Fieldmanager fields
218 */
219 public $remove_default_meta_boxes = False;
220
221 /**
222 * @var string Template
223 * The path to the field template
224 */
225 public $template = Null;
226
227 /**
228 * @var array
229 * If $remove_default_meta_boxes is true, this array will be populated with the list of default meta boxes to remove
230 */
231 public $meta_boxes_to_remove = array();
232
233 /**
234 * @var mixed Default value
235 * The default value for the field, if unset
236 */
237 public $default_value = null;
238
239 /**
240 * @var callable|null
241 * Function that parses an index value and returns an optionally modified value.
242 */
243 public $index_filter = null;
244
245 /**
246 * Input type, mainly to support HTML5 input types.
247 * @var string
248 */
249 public $input_type = 'text';
250
251 /**
252 * Custom escaping for labels, descriptions, etc. Associative array of
253 * $field => $callable arguments, for example:
254 *
255 * 'escape' => array( 'label' => 'wp_kses_post' )
256 *
257 * @var array
258 */
259 public $escape = array();
260
261 /**
262 * @var int
263 * If $this->limit > 1, which element in sequence are we currently rendering?
264 */
265 protected $seq = 0;
266
267 /**
268 * @var boolean
269 * If $is_proto is true, we're rendering the prototype element for a field that can have infinite instances.
270 */
271 protected $is_proto = False;
272
273 /**
274 * @var Fieldmanager_Field
275 * Parent element, if applicable. Would be a Fieldmanager_Group unless third-party plugins support this.
276 */
277 protected $parent = Null;
278
279 /**
280 * @todo Add extra wrapper info rather than this specific.
281 * @var boolean
282 * Render this element in a tab?
283 */
284 protected $is_tab = False;
285
286 /**
287 * Have we added this field as a meta box yet?
288 */
289 private $meta_box_actions_added = False;
290
291 /**
292 * @var boolean
293 * Whether or not this field is present on the attachment edit screen
294 */
295 public $is_attachment = false;
296
297 /**
298 * @var int Global Sequence
299 * The global sequence of elements
300 */
301 private static $global_seq = 0;
302
303 /**
304 * @param mixed string[]|string the value of the element.
305 * @return string HTML for the element.
306 * Generate HTML for the form element itself. Generally should be just one tag, no wrappers.
307 */
308 public function form_element( $value ) {
309 if ( !$this->template ) {
310 $tpl_slug = strtolower( str_replace( 'Fieldmanager_', '', get_class( $this ) ));
311 $this->template = fieldmanager_get_template( $tpl_slug );
312 }
313 ob_start();
314 include $this->template;
315 return ob_get_clean();
316 }
317
318 /**
319 * Superclass constructor, just populates options and sanity-checks common elements.
320 * It might also die, but only helpfully, to catch errors in development.
321 * @param string $label title of form field
322 * @param array $options with keys matching vars of the field in use.
323 */
324 public function __construct( $label = '', $options = array() ) {
325 $this->set_options( $label, $options );
326
327 // A post can only have one parent, so if this saves to post_parent and
328 // it's repeatable, we're doing it wrong.
329 if ( $this->datasource && ! empty( $this->datasource->save_to_post_parent ) && $this->is_repeatable() ) {
330 _doing_it_wrong( 'Fieldmanager_Datasource_Post::$save_to_post_parent', __( 'A post can only have one parent, therefore you cannot store to post_parent in repeatable fields.', 'fieldmanager' ), '1.0.0' );
331 $this->datasource->save_to_post_parent = false;
332 $this->datasource->only_save_to_post_parent = false;
333 }
334 }
335
336 /**
337 * Build options into properties and throw errors if developers add an unsupported opt.
338 * @param string $label title of form field
339 * @param array $options with keys matching vars of the field in use.
340 * @throws FM_Developer_Exception if an option is set but not defined in this class or the child class.
341 * @throws FM_Developer_Exception if an option is set but not public.
342 */
343 public function set_options( $label, $options ) {
344 if ( is_array( $label ) ) {
345 $options = $label;
346 } else {
347 $options['label'] = $label;
348 }
349
350 // Get all the public properties for this object
351 $properties = call_user_func( 'get_object_vars', $this );
352
353 foreach ( $options as $key => $value ) {
354 if ( array_key_exists( $key, $properties ) ) {
355 $this->$key = $value;
356 } elseif ( self::$debug ) {
357 $message = sprintf(
358 __( 'You attempted to set a property "%1$s" that is nonexistant or invalid for an instance of "%2$s" named "%3$s".', 'fieldmanager' ),
359 $key, get_class( $this ), !empty( $options['name'] ) ? $options['name'] : 'NULL'
360 );
361 throw new FM_Developer_Exception( esc_html( $message ) );
362 }
363 }
364
365 // If this is a single field with a limit of 1, serialize_data has no impact
366 if ( ! $this->serialize_data && ! $this->is_group() && 1 == $this->limit ) {
367 $this->serialize_data = true;
368 }
369
370 // Cannot use serialize_data => false with index => true
371 if ( ! $this->serialize_data && $this->index ) {
372 throw new FM_Developer_Exception( esc_html__( 'You cannot use `"serialize_data" => false` with `"index" => true`', 'fieldmanager' ) );
373 }
374 }
375
376 /**
377 * Generates all markup needed for all form elements in this field.
378 * Could be called directly by a plugin or theme.
379 * @param array $values the current values of this element, in a tree structure if the element has children.
380 * @return string HTML for all form elements.
381 */
382 public function element_markup( $values = array() ) {
383 $values = $this->preload_alter_values( $values );
384 if ( $this->limit != 1 ) {
385 $max = max( $this->minimum_count, count( $values ) + $this->extra_elements );
386
387 // Ensure that we don't display more fields than we can save
388 if ( $this->limit > 1 && $max > $this->limit ) {
389 $max = $this->limit;
390 }
391 } else {
392 $max = 1;
393 }
394
395 $classes = array( 'fm-wrapper', 'fm-' . $this->name . '-wrapper' );
396 $fm_wrapper_attrs = array();
397 if ( $this->sortable ) {
398 $classes[] = 'fmjs-sortable';
399 }
400 $classes = array_merge( $classes, $this->get_extra_element_classes() );
401
402 $out = '';
403
404 // If this element is part of tabbed output, there needs to be a wrapper to contain the tab content
405 if ( $this->is_tab ) {
406 $out .= sprintf(
407 '<div id="%s-tab" class="wp-tabs-panel"%s>',
408 esc_attr( $this->get_element_id() ),
409 ( $this->parent->child_count > 0 ) ? ' style="display: none"' : ''
410 );
411 }
412
413 // For lists of items where $one_label_per_item = False, the label should go outside the wrapper.
414 if ( !empty( $this->label ) && !$this->one_label_per_item ) {
415 $out .= $this->get_element_label( array( 'fm-label-for-list' ) );
416 }
417
418 // Find the array position of the "counter" (e.g. in element[0], [0] is the counter, thus the position is 1)
419 $html_array_position = 0; // default is no counter; i.e. if $this->limit = 0
420 if ( $this->limit != 1 ) {
421 $html_array_position = 1; // base situation is formname[0], so the counter is in position 1.
422 if ( $this->parent ) {
423 $parent = $this->parent;
424 while ( $parent ) {
425 $html_array_position++; // one more for having a parent (e.g. parent[this][0])
426 if ( $parent->limit != 1 ) { // and another for the parent having multiple (e.g. parent[0][this][0])
427 $html_array_position++;
428 }
429 $parent = $parent->parent; // parent's parent; root element has null parent which breaks while loop.
430 }
431 }
432 }
433
434 // Checks to see if element has display_if data values, and inserts the data attributes if it does
435 if ( isset( $this->display_if ) && !empty( $this->display_if ) ) {
436 $classes[] = 'display-if';
437 $fm_wrapper_attrs['data-display-src'] = $this->display_if['src'];
438 $fm_wrapper_attrs['data-display-value'] = $this->display_if['value'];
439 }
440 $fm_wrapper_attr_string = '';
441 foreach ( $fm_wrapper_attrs as $attr => $val ) {
442 $fm_wrapper_attr_string .= sprintf( '%s="%s" ', sanitize_key( $attr ), esc_attr( $val ) );
443 }
444 $out .= sprintf( '<div class="%s" data-fm-array-position="%d" %s>',
445 esc_attr( implode( ' ', $classes ) ),
446 absint( $html_array_position ),
447 $fm_wrapper_attr_string
448 );
449
450 // After starting the field, apply a filter to allow other plugins to append functionality
451 $out = apply_filters( 'fm_element_markup_start', $out, $this, $values );
452 if ( ( 0 == $this->limit || ( $this->limit > 1 && $this->limit > $this->minimum_count ) ) && "top" == $this->add_more_position ) {
453 $out .= $this->add_another();
454 }
455
456 if ( 1 != $this->limit ) {
457 $out .= $this->single_element_markup( null, true );
458 }
459 for ( $i = 0; $i < $max; $i++ ) {
460 $this->seq = $i;
461 if ( $this->limit == 1 ) {
462 $value = $values;
463 } else {
464 $value = isset( $values[ $i ] ) ? $values[ $i ] : Null;
465 }
466 $out .= $this->single_element_markup( $value );
467 }
468 if ( ( 0 == $this->limit || ( $this->limit > 1 && $this->limit > $this->minimum_count ) ) && "bottom" == $this->add_more_position ) {
469 $out .= $this->add_another();
470 }
471
472 // Before closing the field, apply a filter to allow other plugins to append functionality
473 $out = apply_filters( 'fm_element_markup_end', $out, $this, $values );
474
475 $out .= '</div>';
476
477 // Close the tab wrapper if one exists
478 if ( $this->is_tab ) $out .= '</div>';
479
480 return $out;
481 }
482
483 /**
484 * Generate wrappers and labels for one form element.
485 * Is called by element_markup(), calls form_element().
486 * @see Fieldmanager_Field::element_markup()
487 * @see Fieldmanager_Field::form_element()
488 * @param mixed $value the current value of this element.
489 * @param boolean $is_proto true to generate a prototype element for Javascript.
490 * @return string HTML for a single form element.
491 */
492 public function single_element_markup( $value = Null, $is_proto = False ) {
493 if ( $is_proto ) {
494 $this->is_proto = true;
495 }
496 $out = '';
497 $classes = array( 'fm-item', 'fm-' . $this->name );
498
499 self::$global_seq++;
500
501 // Drop the fm-group class to hide inner box display if no label is set
502 if ( !( $this->is_group() && ( !isset( $this->label ) || empty( $this->label ) ) ) ) {
503 $classes[] = 'fm-' . $this->field_class;
504 }
505
506 // Check if the required attribute is set. If so, add the class.
507 if ( $this->required ) {
508 $classes[] = 'form-required';
509 }
510
511 if ( $is_proto ) {
512 $classes[] = 'fmjs-proto';
513 }
514
515 if ( $this->is_group() && 'vertical' === $this->tabbed ) {
516 $classes[] = 'fm-tabbed-vertical';
517 }
518
519 $classes = apply_filters( 'fm_element_classes', $classes, $this->name, $this );
520
521 $out .= sprintf( '<div class="%s">', esc_attr( implode( ' ', $classes ) ) );
522
523 $label = $this->get_element_label( );
524 $render_label_after = False;
525 // Hide the label if it is empty or if this is a tab since it would duplicate the title from the tab label
526 if ( !empty( $this->label ) && !$this->is_tab && $this->one_label_per_item ) {
527 if ( $this->limit != 1 ) {
528 $out .= $this->wrap_with_multi_tools( $label, array( 'fmjs-removable-label' ) );
529 } elseif ( !$this->label_after_element ) {
530 $out .= $label;
531 } else {
532 $render_label_after = True;
533 }
534 }
535
536 if ( isset( $this->description ) && !empty( $this->description ) && ! $this->description_after_element ) {
537 $out .= sprintf( '<div class="fm-item-description">%s</div>', $this->escape( 'description' ) );
538 }
539
540 if ( Null === $value && Null !== $this->default_value )
541 $value = $this->default_value;
542
543 $form_element = $this->form_element( $value );
544
545 if ( $this->limit != 1 && ( ! $this->one_label_per_item || empty( $this->label ) ) ) {
546 $out .= $this->wrap_with_multi_tools( $form_element );
547 } else {
548 $out .= $form_element;
549 }
550
551 if ( $render_label_after ) $out .= $label;
552
553 if ( isset( $this->description ) && !empty( $this->description ) && $this->description_after_element ) {
554 $out .= sprintf( '<div class="fm-item-description">%s</div>', $this->escape( 'description' ) );
555 }
556
557 $out .= '</div>';
558
559 if ( $is_proto ) {
560 $this->is_proto = false;
561 }
562 return $out;
563 }
564
565 /**
566 * Alter values before rendering
567 * @param array $values
568 */
569 public function preload_alter_values( $values ) {
570 return apply_filters( 'fm_preload_alter_values', $values, $this );
571 }
572
573 /**
574 * Wrap a chunk of HTML with "remove" and "move" buttons if applicable.
575 * @param string $html HTML to wrap.
576 * @return string wrapped HTML.
577 */
578 public function wrap_with_multi_tools( $html, $classes = array() ) {
579 $classes[] = 'fmjs-removable';
580 $out = sprintf( '<div class="%s">', implode( ' ', $classes ) );
581 if ( $this->sortable ) {
582 $out .= $this->get_sort_handle();
583 }
584 $out .= '<div class="fmjs-removable-element">';
585 $out .= $html;
586 $out .= '</div>';
587
588 if ( $this->limit == 0 || $this->limit > $this->minimum_count ) {
589 $out .= $this->get_remove_handle();
590 }
591
592 $out .= '</div>';
593 return $out;
594 }
595
596 /**
597 * Get HTML form name (e.g. questions[answer]).
598 * @return string form name
599 */
600 public function get_form_name( $multiple = "" ) {
601 $tree = $this->get_form_tree();
602 $name = '';
603 foreach ( $tree as $level => $branch ) {
604 if ( 0 == $level ) {
605 $name .= $branch->name;
606 } else {
607 $name .= '[' . $branch->name . ']';
608 }
609 if ( $branch->limit != 1 ) {
610 $name .= '[' . $branch->get_seq() . ']';
611 }
612 }
613 return $name . $multiple;
614 }
615
616 /**
617 * Recursively build path to this element (e.g. array(grandparent, parent, this) )
618 * @return array of parents
619 */
620 public function get_form_tree() {
621 $tree = array();
622 if ( $this->parent ) {
623 $tree = $this->parent->get_form_tree();
624 }
625 $tree[] = $this;
626 return $tree;
627 }
628
629 /**
630 * Get the ID for the form element itself, uses $this->seq (e.g. which position is this element in).
631 * Relying on the element's ID for anything isn't a great idea since it can be rewritten in JS.
632 * @return string ID for use in a form element.
633 */
634 public function get_element_id() {
635 $el = $this;
636 $id_slugs = array();
637 while ( $el ) {
638 $slug = $el->is_proto ? 'proto' : $el->seq;
639 array_unshift( $id_slugs, $el->name . '-' . $slug );
640 $el = $el->parent;
641 }
642 return 'fm-' . implode( '-', $id_slugs );
643 }
644
645 /**
646 * Get the storage key for the form element.
647 *
648 * @return string
649 */
650 public function get_element_key() {
651 $el = $this;
652 $key = $el->name;
653 while ( $el = $el->parent ) {
654 if ( $el->add_to_prefix ) {
655 $key = "{$el->name}_{$key}";
656 }
657 }
658 return $key;
659 }
660
661 /**
662 * Is this element repeatable or does it have a repeatable ancestor?
663 *
664 * @return boolean True if yes, false if no.
665 */
666 public function is_repeatable() {
667 if ( 1 != $this->limit ) {
668 return true;
669 } elseif ( $this->parent ) {
670 return $this->parent->is_repeatable();
671 }
672 return false;
673 }
674
675 /**
676 * Is the current field a group?
677 *
678 * @return boolean True if yes, false if no.
679 */
680 public function is_group() {
681 return $this instanceof Fieldmanager_Group;
682 }
683
684 /**
685 * Presaves all elements in what could be a set of them, dispatches to $this->presave()
686 * @input mixed[] $values
687 * @return mixed[] sanitized values
688 */
689 public function presave_all( $values, $current_values ) {
690 if ( $this->limit == 1 && empty( $this->multiple ) ) {
691 $values = $this->presave_alter_values( array( $values ), array( $current_values ) );
692 if ( ! empty( $values ) ) {
693 $value = $this->presave( $values[0], $current_values );
694 } else {
695 $value = $values;
696 }
697 if ( !empty( $this->index ) ) {
698 $this->save_index( array( $value ), array( $current_values ) );
699 }
700 return $value;
701 }
702
703 // If $this->limit != 1, and $values is not an array, that'd just be wrong, and possibly an attack, so...
704 if ( $this->limit != 1 && !is_array( $values ) ) {
705
706 // EXCEPT maybe this is a request to remove indices
707 if ( ! empty( $this->index ) && null === $values && ! empty( $current_values ) && is_array( $current_values ) ) {
708 $this->save_index( null, $current_values );
709 return;
710 }
711
712 // OR doing cron, where we should just do nothing if there are no values to process.
713 // OR we've now accumulated some cases where a null value instead of an empty array is an acceptable case to
714 // just bail out instead of throwing an error. If it WAS an attack, bailing should prevent damage.
715 if ( null === $values || ( defined( 'DOING_CRON' ) && DOING_CRON && empty( $values ) ) ) {
716 return;
717 }
718
719 $this->_unauthorized_access( sprintf( __( '$values should be an array because $limit is %d', 'fieldmanager' ), $this->limit ) );
720 }
721
722 if ( empty( $values ) ) {
723 $values = array();
724 }
725
726 // Remove the proto
727 if ( isset( $values['proto'] ) ) {
728 unset( $values['proto'] );
729 }
730
731 // If $this->limit is not 0 or 1, and $values has more than $limit, that could also be an attack...
732 if ( $this->limit > 1 && count( $values ) > $this->limit ) {
733 $this->_unauthorized_access(
734 sprintf( __( 'submitted %1$d values against a limit of %2$d', 'fieldmanager' ), count( $values ), $this->limit )
735 );
736 }
737
738 // Check for non-numeric keys
739 $keys = array_keys( $values );
740 foreach ( $keys as $key ) {
741 if ( ! is_numeric( $key ) ) {
742 throw new FM_Exception( esc_html__( 'Use of a non-numeric key suggests that something is wrong with this group.', 'fieldmanager' ) );
743 }
744 }
745
746 // Condense the array to account for middle items removed
747 $values = array_values( $values );
748
749 $values = $this->presave_alter_values( $values, $current_values );
750
751 // If this update results in fewer children, trigger presave on empty children to make up the difference.
752 if ( ! empty( $current_values ) && is_array( $current_values ) ) {
753 foreach ( array_diff( array_keys( $current_values ), array_keys( $values ) ) as $i ) {
754 $values[ $i ] = null;
755 }
756 }
757
758 foreach ( $values as $i => $value ) {
759 $values[ $i ] = $this->presave( $value, empty( $current_values[ $i ] ) ? array() : $current_values[ $i ] );
760 }
761
762 if ( ! $this->save_empty ) {
763 // reindex the array after removing empty values
764 $values = array_values( array_filter( $values ) );
765 }
766
767 if ( ! empty( $this->index ) ) {
768 $this->save_index( $values, $current_values );
769 }
770
771 return $values;
772 }
773
774 /**
775 * Optionally save fields to a separate postmeta index for easy lookup with WP_Query
776 * Handles internal arrays (e.g. for fieldmanager-options).
777 * Is called multiple times for multi-fields (e.g. limit => 0)
778 * @param array $values
779 * @return void
780 * @todo make this a context method
781 */
782 protected function save_index( $values, $current_values ) {
783 if ( $this->data_type != 'post' || empty( $this->data_id ) ) return;
784 // Must delete current values specifically, then add new ones, to support a scenario where the
785 // same field in repeating groups with limit = 1 is going to create more than one entry here, and
786 // if we called update_post_meta() we would overwrite the index with each new group.
787 if ( ! empty( $current_values ) && is_array( $current_values ) ) {
788 foreach ( $current_values as $old_value ) {
789 if ( !is_array( $old_value ) ) $old_value = array( $old_value );
790 foreach ( $old_value as $value ) {
791 $value = $this->process_index_value( $value );
792 if ( empty( $value ) ) $value = 0; // false or null should be saved as 0 to prevent duplicates
793 delete_post_meta( $this->data_id, $this->index, $value );
794 }
795 }
796 }
797 // add new values
798 if ( ! empty( $values ) && is_array( $values ) ) {
799 foreach ( $values as $new_value ) {
800 if ( !is_array( $new_value ) ) $new_value = array( $new_value );
801 foreach ( $new_value as $value ) {
802 $value = $this->process_index_value( $value );
803 if ( isset( $value ) ) {
804 if ( empty( $value ) ) $value = 0; // false or null should be saved as 0 to prevent duplicates
805 add_post_meta( $this->data_id, $this->index, $value );
806 }
807 }
808 }
809 }
810 }
811
812 /**
813 * Hook to alter handling of an individual index value, which may make sense to change per field type.
814 * @param mixed $value
815 * @return mixed
816 */
817 protected function process_index_value( $value ) {
818 if ( is_callable( $this->index_filter ) ) {
819 $value = call_user_func( $this->index_filter, $value );
820 }
821
822 return apply_filters( 'fm_process_index_value', $value, $this );
823 }
824
825 /**
826 * Hook to alter or respond to all the values of a particular element
827 * @param array $values
828 * @return array
829 */
830 protected function presave_alter_values( $values, $current_values = array() ) {
831 return apply_filters( 'fm_presave_alter_values', $values, $this, $current_values );
832 }
833
834 /**
835 * Presave function, which handles sanitization and validation
836 * @param mixed $value If a single field expects to manage an array, it must override presave()
837 * @return sanitized values.
838 */
839 public function presave( $value, $current_value = array() ) {
840 // It's possible that some elements (Grid is one) would be arrays at
841 // this point, but those elements must override this function. Let's
842 // make sure we're dealing with one value here.
843 if ( is_array( $value ) ) {
844 $this->_unauthorized_access( __( 'presave() in the base class should not get arrays, but did.', 'fieldmanager' ) );
845 }
846 foreach ( $this->validate as $func ) {
847 if ( !call_user_func( $func, $value ) ) {
848 $this->_failed_validation( sprintf(
849 __( 'Input "%1$s" is not valid for field "%2$s" ', 'fieldmanager' ),
850 (string) $value,
851 $this->label
852 ) );
853 }
854 }
855 return call_user_func( $this->sanitize, $value );
856 }
857
858 /**
859 * Generates an HTML attribute string based on the value of $this->attributes.
860 * @see Fieldmanager_Field::$attributes
861 * @return string attributes ready to insert into an HTML tag.
862 */
863 public function get_element_attributes() {
864 $attr_str = array();
865 foreach ( $this->attributes as $attr => $val ) {
866 if ( $val === true ){
867 $attr_str[] = sanitize_key( $attr );
868 } else{
869 $attr_str[] = sprintf( '%s="%s"', sanitize_key( $attr ), esc_attr( $val ) );
870 }
871 }
872 return implode( ' ', $attr_str );
873 }
874
875 /**
876 * Get an HTML label for this element.
877 * @param array $classes extra CSS classes.
878 * @return string HTML label.
879 */
880 public function get_element_label( $classes = array() ) {
881 $classes[] = 'fm-label';
882 $classes[] = 'fm-label-' . $this->name;
883 if ( $this->inline_label ) {
884 $this->label_element = 'span';
885 $classes[] = 'fm-label-inline';
886 }
887 if ( $this->label_after_element ) {
888 $classes[] = 'fm-label-after';
889 }
890 return sprintf(
891 '<%s class="%s"><label for="%s">%s</label></%s>',
892 sanitize_key( $this->label_element ),
893 esc_attr( implode( ' ', $classes ) ),
894 esc_attr( $this->get_element_id( $this->get_seq() ) ),
895 $this->escape( 'label' ),
896 sanitize_key( $this->label_element )
897 );
898 }
899
900 /**
901 * Generates HTML for the "Add Another" button.
902 * @return string button HTML.
903 */
904 public function add_another() {
905 $classes = array( 'fm-add-another', 'fm-' . $this->name . '-add-another', 'button-secondary' );
906 if ( empty( $this->add_more_label ) ) {
907 $this->add_more_label = $this->is_group() ? __( 'Add group', 'fieldmanager' ) : __( 'Add field', 'fieldmanager' );
908 }
909
910 $out = '<div class="fm-add-another-wrapper">';
911 $out .= sprintf(
912 '<input type="button" class="%s" value="%s" name="%s" data-related-element="%s" data-add-more-position="%s" data-limit="%d" />',
913 esc_attr( implode( ' ', $classes ) ),
914 esc_attr( $this->add_more_label ),
915 esc_attr( 'fm_add_another_' . $this->name ),
916 esc_attr( $this->name ),
917 esc_attr( $this->add_more_position ),
918 intval( $this->limit )
919 );
920 $out .= '</div>';
921 return $out;
922 }
923
924 /**
925 * Return HTML for the sort handle (multi-tools); a separate function to override
926 * @return string
927 */
928 public function get_sort_handle() {
929 return sprintf( '<div class="fmjs-drag fmjs-drag-icon"><span class="screen-reader-text">%s</span></div>', esc_html__( 'Move', 'fieldmanager' ) );
930 }
931
932 /**
933 * Return HTML for the remove handle (multi-tools); a separate function to override
934 * @return string
935 */
936 public function get_remove_handle() {
937 return sprintf( '<a href="#" class="fmjs-remove" title="%1$s"><span class="screen-reader-text">%1$s</span></a>', esc_attr__( 'Remove', 'fieldmanager' ) );
938 }
939
940 /**
941 * Return HTML for the collapse handle (multi-tools); a separate function to override
942 * @return string
943 */
944 public function get_collapse_handle() {
945 return '<span class="toggle-indicator" aria-hidden="true"></span>';
946 }
947
948 /**
949 * Return extra element classes; overriden by some fields.
950 * @return array
951 */
952 public function get_extra_element_classes() {
953 return array();
954 }
955
956 /**
957 * Add a form on user pages
958 * @param string $title
959 */
960 public function add_user_form( $title = '' ) {
961 $this->require_base();
962 return new Fieldmanager_Context_User( $title, $this );
963 }
964
965 /**
966 * Add a form on a frontend page
967 * @see Fieldmanager_Context_Form
968 * @param string $uniqid a unique identifier for this form
969 */
970 public function add_page_form( $uniqid ) {
971 $this->require_base();
972 return new Fieldmanager_Context_Page( $uniqid, $this );
973 }
974
975 /**
976 * Add a form on a term add/edit page
977 *
978 * @deprecated 1.0.0-beta.3 Replaced by {@see Fieldmanager_Field::add_term_meta_box()}.
979 *
980 * @see Fieldmanager_Context_Term
981 *
982 * @param string $title
983 * @param string|array $taxonomies The taxonomies on which to display this form
984 * @param boolean $show_on_add Whether or not to show the fields on the add term form
985 * @param boolean $show_on_edit Whether or not to show the fields on the edit term form
986 * @param int $parent Only show this field on child terms of this parent term ID
987 */
988 public function add_term_form( $title, $taxonomies, $show_on_add = true, $show_on_edit = true, $parent = '' ) {
989 $this->require_base();
990 return new Fieldmanager_Context_Term( array(
991 'title' => $title,
992 'taxonomies' => $taxonomies,
993 'show_on_add' => $show_on_add,
994 'show_on_edit' => $show_on_edit,
995 'parent' => $parent,
996 // Use the deprecated FM Term Meta instead of core's term meta
997 'use_fm_meta' => true,
998 'field' => $this,
999 ) );
1000 }
1001
1002 /**
1003 * Add fields to the term add/edit page
1004 *
1005 * @see Fieldmanager_Context_Term
1006 *
1007 * @param string $title
1008 * @param string|array $taxonomies The taxonomies on which to display this form
1009 * @param boolean $show_on_add Whether or not to show the fields on the add term form
1010 * @param boolean $show_on_edit Whether or not to show the fields on the edit term form
1011 * @param int $parent Only show this field on child terms of this parent term ID
1012 */
1013 public function add_term_meta_box( $title, $taxonomies, $show_on_add = true, $show_on_edit = true, $parent = '' ) {
1014 // Bail if term meta table is not installed.
1015 if ( get_option( 'db_version' ) < 34370 ) {
1016 _doing_it_wrong( __METHOD__, esc_html__( 'This method requires WordPress 4.4 or above', 'fieldmanager' ), 'Fieldmanager-1.0.0-beta.3' );
1017 return false;
1018 }
1019
1020 $this->require_base();
1021 return new Fieldmanager_Context_Term( array(
1022 'title' => $title,
1023 'taxonomies' => $taxonomies,
1024 'show_on_add' => $show_on_add,
1025 'show_on_edit' => $show_on_edit,
1026 'parent' => $parent,
1027 'use_fm_meta' => false,
1028 'field' => $this,
1029 ) );
1030 }
1031
1032 /**
1033 * Add this field as a metabox to a post type
1034 * @see Fieldmanager_Context_Post
1035 * @param string $title
1036 * @param string|string[] $post_type
1037 * @param string $context
1038 * @param string $priority
1039 */
1040 public function add_meta_box( $title, $post_types, $context = 'normal', $priority = 'default' ) {
1041 $this->require_base();
1042 // Check if any default meta boxes need to be removed for this field
1043 $this->add_meta_boxes_to_remove( $this->meta_boxes_to_remove );
1044 if ( in_array( 'attachment', (array) $post_types ) ) {
1045 $this->is_attachment = true;
1046 }
1047 return new Fieldmanager_Context_Post( $title, $post_types, $context, $priority, $this );
1048 }
1049
1050 /**
1051 * Add this field to a post type's quick edit box.
1052 * @see Fieldmanager_Context_Quickedit
1053 * @param string $title
1054 * @param string|string[] $post_type
1055 * @param string $column_title
1056 * @param callable $column_display_callback
1057 */
1058 public function add_quickedit_box( $title, $post_types, $column_display_callback, $column_title = '' ) {
1059 $this->require_base();
1060 return new Fieldmanager_Context_QuickEdit( $title, $post_types, $column_display_callback, $column_title, $this );
1061 }
1062
1063 /**
1064 * Add this group to an options page
1065 * @param string $title
1066 */
1067 public function add_submenu_page( $parent_slug, $page_title, $menu_title = Null, $capability = 'manage_options', $menu_slug = Null ) {
1068 $this->require_base();
1069 return new Fieldmanager_Context_Submenu( $parent_slug, $page_title, $menu_title, $capability, $menu_slug, $this );
1070 }
1071
1072 /**
1073 * Activate this group in an already-added submenu page
1074 * @param string $title
1075 */
1076 public function activate_submenu_page() {
1077 $this->require_base();
1078 $submenus = _fieldmanager_registry( 'submenus' );
1079 $s = $submenus[ $this->name ];
1080 $active_submenu = new Fieldmanager_Context_Submenu( $s[0], $s[1], $s[2], $s[3], $s[4], $this, True );
1081 _fieldmanager_registry( 'active_submenu', $active_submenu );
1082 }
1083
1084 private function require_base() {
1085 if ( !empty( $this->parent ) ) {
1086 throw new FM_Developer_Exception( esc_html__( 'You cannot use this method on a subgroup', 'fieldmanager' ) );
1087 }
1088 }
1089
1090 /**
1091 * Die violently. If self::$debug is true, throw an exception.
1092 * @param string $debug_message
1093 * @return void e.g. return _you_ into a void.
1094 */
1095 public function _unauthorized_access( $debug_message = '' ) {
1096 if ( self::$debug ) {
1097 throw new FM_Exception( esc_html( $debug_message ) );
1098 }
1099 else {
1100 wp_die( esc_html__( "Sorry, you're not supposed to do that...", 'fieldmanager' ) );
1101 }
1102 }
1103
1104 /**
1105 * Fail validation. If self::$debug is true, throw an exception.
1106 * @param string $error_message
1107 * @return void
1108 */
1109 protected function _failed_validation( $debug_message = '' ) {
1110 if ( self::$debug ) {
1111 throw new FM_Validation_Exception( $debug_message );
1112 }
1113 else {
1114 wp_die( esc_html(
1115 $debug_message . "\n\n" .
1116 __( "You may be able to use your browser's back button to resolve this error.", 'fieldmanager' )
1117 ) );
1118 }
1119 }
1120
1121 /**
1122 * Die violently. If self::$debug is true, throw an exception.
1123 * @param string $debug_message
1124 * @return void e.g. return _you_ into a void.
1125 */
1126 public function _invalid_definition( $debug_message = '' ) {
1127 if ( self::$debug ) {
1128 throw new FM_Exception( esc_html( $debug_message ) );
1129 } else {
1130 wp_die( esc_html__( "Sorry, you've created an invalid field definition. Please check your code and try again.", 'fieldmanager' ) );
1131 }
1132 }
1133
1134 /**
1135 * In a multiple element set, return the index of the current element we're rendering.
1136 * @return int
1137 */
1138 protected function get_seq() {
1139 return $this->has_proto() ? 'proto' : $this->seq;
1140 }
1141
1142 /**
1143 * Are we in the middle of generating a prototype element for repeatable fields?
1144 * @return boolean
1145 */
1146 protected function has_proto() {
1147 if ( $this->is_proto ) return True;
1148 if ( $this->parent ) return $this->parent->has_proto();
1149 return False;
1150 }
1151
1152 /**
1153 * Helper function to add to the list of meta boxes to remove. This will be defined in child classes that require this functionality.
1154 * @param array current list of meta boxes to remove
1155 * @return void
1156 */
1157 protected function add_meta_boxes_to_remove( &$meta_boxes_to_remove ) {}
1158
1159 /**
1160 * Escape a field based on the function in the escape argument.
1161 *
1162 * @param string $field The field to escape.
1163 * @param string $default The default function to use to escape the field.
1164 * Optional. Defaults to `esc_html()`
1165 * @return string The escaped field.
1166 */
1167 public function escape( $field, $default = 'esc_html' ) {
1168 if ( isset( $this->escape[ $field ] ) && is_callable( $this->escape[ $field ] ) ) {
1169 return call_user_func( $this->escape[ $field ], $this->$field );
1170 } else {
1171 return call_user_func( $default, $this->$field );
1172 }
1173 }
1174 }
1175