Project

General

Profile

Paste
Download (23.1 KB) Statistics
| Branch: | Revision:

root / drupal7 / sites / all / modules / webform / includes / webform.webformconditionals.inc @ 01f36513

1
<?php
2

    
3
/**
4
 * @file
5
 * Conditional engine to process dependencies within the webform's conditionals.
6
 */
7

    
8
/**
9
 * Performs analysis and topological sorting on the conditionals.
10
 */
11
class WebformConditionals {
12
  /**
13
   * Define constants.
14
   *
15
   * Define constants for the result of an analysis of the conditionals on a
16
   * page for a given set of input values. Determines whether the component is
17
   * always hidden, always shown, or may or may not be shown depending upon
18
   * other values on the same page. In the last case, the component needs to be
19
   * rendered on the page because at least one source component is on the same
20
   * page. The field will be hidden with JavaScript.
21
   *
22
   * @var int
23
   */
24
  const componentHidden = 0;
25
  const componentShown = 1;
26
  const componentDependent = 2;
27

    
28
  protected static $conditionals = array();
29

    
30
  protected $node;
31
  protected $topologicalOrder;
32
  protected $pageMap;
33
  protected $childrenMap;
34
  protected $visibilityMap;
35
  protected $requiredMap;
36
  protected $setMap;
37
  protected $markupMap;
38

    
39
  public $errors;
40

    
41
  /**
42
   * Creates and caches a WebformConditional for a given node.
43
   */
44
  public static function factory($node) {
45
    if (!isset(self::$conditionals[$node->nid])) {
46
      self::$conditionals[$node->nid] = new WebformConditionals($node);
47
    }
48
    return self::$conditionals[$node->nid];
49
  }
50

    
51
  /**
52
   * Constructs a WebformConditional.
53
   */
54
  public function __construct($node) {
55
    $this->node = $node;
56
  }
57

    
58
  /**
59
   * Sorts the conditionals into topological order.
60
   *
61
   * The "nodes" of the list are the conditionals, not the components that
62
   * they operate upon.
63
   *
64
   * The webform components must already be sorted into component tree order
65
   * before calling this method.
66
   *
67
   * See http://en.wikipedia.org/wiki/Topological_sorting
68
   */
69
  protected function topologicalSort() {
70
    $components = $this->node->webform['components'];
71
    $conditionals = $this->node->webform['conditionals'];
72
    $errors = array();
73

    
74
    // Generate a component to conditional map for conditional targets.
75
    $cid_to_target_rgid = array();
76
    $cid_hidden = array();
77
    foreach ($conditionals as $rgid => $conditional) {
78
      foreach ($conditional['actions'] as $aid => $action) {
79
        $target_id = $action['target'];
80
        $cid_to_target_rgid[$target_id][$rgid] = $rgid;
81
        if ($action['action'] == 'show') {
82
          $cid_hidden[$target_id] = isset($cid_hidden[$target_id]) ? $cid_hidden[$target_id] + 1 : 1;
83
          if ($cid_hidden[$target_id] == 2) {
84
            $component = $components[$target_id];
85
            $errors[$component['page_num']][] = t('More than one conditional hides or shows component "@name".',
86
                                                  array('@name' => $component['name']));
87
          }
88
        }
89
      }
90
    }
91

    
92
    // Generate T-Orders for each page.
93
    $new_entry = array('in' => array(), 'out' => array(), 'rgid' => array());
94
    $page_num = 0;
95
    // If the first component is a page break, then no component is on page 1. Create empty arrays for page 1.
96
    $sorted = array(1 => array());
97
    $page_map = array(1 => array());
98
    $component = reset($components);
99
    while ($component) {
100
      $cid = $component['cid'];
101

    
102
      // Start a new page, if needed.
103
      if ($component['page_num'] > $page_num) {
104
        $page_num = $component['page_num'];
105
        // Create an empty list that will contain the sorted elements.
106
        // This list is known as L in the literature.
107
        $sorted[$page_num] = array();
108

    
109
        // Create an empty list of dependency nodes for this page.
110
        $nodes = array();
111
      }
112

    
113
      // Create the pageMap as a side benefit of generating the t-sort.
114
      $page_map[$page_num][$cid] = $cid;
115

    
116
      // Process component by adding it's conditional data to a component-tree-traversal order an index of:
117
      // - incoming dependencies = the source components for the conditions for this target component and
118
      // - outgoing dependencies = components which depend upon the target component
119
      // Note: Surprisingly, 0 is a valid rgid, as well as a valid rid. Use -1 as a semaphore.
120
      if (isset($cid_to_target_rgid[$cid])) {
121
        // The component is the target of conditional(s)
122
        foreach ($cid_to_target_rgid[$cid] as $rgid) {
123
          $conditional = $conditionals[$rgid];
124
          if (!isset($nodes[$cid])) {
125
            $nodes[$cid] = $new_entry;
126
          }
127
          $nodes[$cid]['rgid'][$rgid] = $rgid;
128
          foreach ($conditional['rules'] as $rule) {
129
            if ($rule['source_type'] == 'component') {
130
              $source_id = $rule['source'];
131
              if (!isset($nodes[$source_id])) {
132
                $nodes[$source_id] = $new_entry;
133
              }
134
              $nodes[$cid]['in'][$source_id] = $source_id;
135
              $nodes[$source_id]['out'][$cid] = $cid;
136
              $source_component = $components[$source_id];
137
              $source_pid = $source_component['pid'];
138
              if ($source_pid) {
139
                if (!isset($nodes[$source_pid])) {
140
                  $nodes[$source_pid] = $new_entry;
141
                }
142
                // The rule source is within a parent fieldset. Create a dependency on the parent.
143
                $nodes[$source_pid]['out'][$source_id] = $source_id;
144
                $nodes[$source_id]['in'][$source_pid] = $source_pid;
145
              }
146
              if ($source_component['page_num'] > $page_num) {
147
                $errors[$page_num][] = t('A forward reference from page @from, %from to %to was found.',
148
                  array(
149
                    '%from' => $source_component['name'],
150
                    '@from' => $source_component['page_num'],
151
                    '%to' => $component['name'],
152
                  ));
153
              }
154
              elseif ($source_component['page_num'] == $page_num && $component['type'] == 'pagebreak') {
155
                $errors[$page_num][] = t('The page break %to can\'t be controlled by %from on the same page.',
156
                  array(
157
                    '%from' => $source_component['name'],
158
                    '%to' => $component['name'],
159
                  ));
160
              }
161
            }
162
          }
163
        }
164
      }
165

    
166
      // Fetch the next component, if any.
167
      $component = next($components);
168

    
169
      // Finish any previous page already processed.
170
      if (!$component || $component['page_num'] > $page_num) {
171

    
172
        // Create a set of all components which have are not dependent upon anything.
173
        // This list is known as S in the literature.
174
        $start_nodes = array();
175
        foreach ($nodes as $id => $n) {
176
          if (!$n['in']) {
177
            $start_nodes[] = $id;
178
          }
179
        }
180

    
181
        // Process the start nodes, removing each one in turn from the queue.
182
        while ($start_nodes) {
183
          $id = array_shift($start_nodes);
184
          // If the node represents an actual conditional, it can now be added
185
          // to the end of the sorted order because anything it depends upon has
186
          // already been calculated.
187
          if ($nodes[$id]['rgid']) {
188
            foreach ($nodes[$id]['rgid'] as $rgid) {
189
              $sorted[$page_num][] = array(
190
                'cid' => $id,
191
                'rgid' => $rgid,
192
                'name' => $components[$id]['name'],
193
              );
194

    
195
            }
196
          }
197

    
198
          // Any other nodes that depend upon this node may now have their dependency
199
          // on this node removed, since it has now been calculated.
200
          foreach ($nodes[$id]['out'] as $out_id) {
201
            unset($nodes[$out_id]['in'][$id]);
202
            if (!$nodes[$out_id]['in']) {
203
              $start_nodes[] = $out_id;
204
            }
205
          }
206

    
207
          // All out-going dependencies have been handled.
208
          $nodes[$id]['out'] = array();
209
        }
210

    
211
        // Check for a cyclic graph (circular dependency).
212
        foreach ($nodes as $id => $n) {
213
          if ($n['in'] || $n['out']) {
214
            $errors[$page_num][] = t('A circular reference involving %name was found.',
215
              array('%name' => $components[$id]['name']));
216
          }
217
        }
218

    
219
      } // End finishing previous page.
220

    
221
    } // End component loop.
222

    
223
    // Create an empty page map for the preview page.
224
    $page_map[$page_num + 1] = array();
225

    
226
    $this->topologicalOrder = $sorted;
227
    $this->errors = $errors;
228
    $this->pageMap = $page_map;
229
  }
230

    
231
  /**
232
   * Returns the (possibly cached) topological sort order.
233
   */
234
  public function getOrder() {
235
    if (!$this->topologicalOrder) {
236
      $this->topologicalSort();
237
    }
238
    return $this->topologicalOrder;
239
  }
240

    
241
  /**
242
   * Returns an index of components by page number.
243
   */
244
  public function getPageMap() {
245
    if (!$this->pageMap) {
246
      $this->topologicalSort();
247
    }
248
    return $this->pageMap;
249
  }
250

    
251
  /**
252
   * Displays and error messages from the previously-generated sort order.
253
   *
254
   * User's who can't fix the webform are shown a single, simplified message.
255
   */
256
  public function reportErrors() {
257
    $this->getOrder();
258
    if ($this->errors) {
259
      if (webform_node_update_access($this->node)) {
260
        foreach ($this->errors as $page_num => $page_errors) {
261
          drupal_set_message(format_plural(count($page_errors),
262
            'Conditional error on page @num:',
263
            'Conditional errors on page @num:',
264
            array('@num' => $page_num)) .
265
            '<br /><ul><li>' . implode('</li><li>', $page_errors) . '</li></ul>', 'warning');
266
        }
267
      }
268
      else {
269
        drupal_set_message(t('This form is improperly configured. Contact the administrator.'), 'warning');
270
      }
271
    }
272
  }
273

    
274
  /**
275
   * Creates and caches a map of the children of a each component.
276
   *
277
   * Called after the component tree has been made and then flattened again.
278
   * Alas, the children data is removed when the tree is flattened. The
279
   * components are indexed by cid but in tree order. Because cid's are
280
   * numeric, they may not appear in IDE's or debuggers in their actual order.
281
   */
282
  public function getChildrenMap() {
283
    if (!$this->childrenMap) {
284
      $map = array();
285
      foreach ($this->node->webform['components'] as $cid => $component) {
286
        $pid = $component['pid'];
287
        if ($pid) {
288
          $map[$pid][] = $cid;
289
        }
290
      }
291

    
292
      $this->childrenMap = $map;
293
    }
294
    return $this->childrenMap;
295
  }
296

    
297
  /**
298
   * Deletes the value of the given component, plus any descendants.
299
   */
300
  protected function deleteFamily(&$input_values, $parent_id, &$page_visiblity_page) {
301
    if (isset($input_values[$parent_id])) {
302
      $input_values[$parent_id] = NULL;
303
    }
304
    if (isset($this->childrenMap[$parent_id])) {
305
      foreach ($this->childrenMap[$parent_id] as $child_id) {
306
        $page_visiblity_page[$child_id] = $page_visiblity_page[$parent_id];
307
        $this->deleteFamily($input_values, $child_id, $page_visiblity_page);
308
      }
309
    }
310
  }
311

    
312
  protected $stackPointer;
313
  protected $resultStack;
314

    
315
  /**
316
   * Initializes an execution stack for a conditional group's rules.
317
   *
318
   * Also initializes sub-conditional rules.
319
   */
320
  public function executionStackInitialize($andor) {
321
    $this->stackPointer = -1;
322
    $this->resultStack = array();
323
    $this->executionStackPush($andor);
324
  }
325

    
326
  /**
327
   * Starts a new subconditional for the given and/or operator.
328
   */
329
  public function executionStackPush($andor) {
330
    $this->resultStack[++$this->stackPointer] = array(
331
      'results' => array(),
332
      'andor' => $andor,
333
    );
334
  }
335

    
336
  /**
337
   * Adds a rule's result to the current sub-conditional.
338
   */
339
  public function executionStackAccumulate($result) {
340
    $this->resultStack[$this->stackPointer]['results'][] = $result;
341
  }
342

    
343
  /**
344
   * Finishes a sub-conditional and adds the result to the parent stack frame.
345
   */
346
  public function executionStackPop() {
347
    // Calculate the and/or result.
348
    $stack_frame = $this->resultStack[$this->stackPointer];
349
    // Pop stack and protect against stack underflow.
350
    $this->stackPointer = max(0, $this->stackPointer - 1);
351
    $conditional_results = $stack_frame['results'];
352
    $filtered_results = array_filter($conditional_results);
353
    return $stack_frame['andor'] === 'or'
354
              ? count($filtered_results) > 0
355
              : count($filtered_results) === count($conditional_results);
356
  }
357

    
358
  /**
359
   * Executes the conditionals on a submission.
360
   *
361
   * This removes any data which should be hidden.
362
   */
363
  public function executeConditionals($input_values, $page_num = 0) {
364
    $this->getOrder();
365
    $this->getChildrenMap();
366
    if (!$this->visibilityMap || $page_num == 0) {
367
      // Create a new visibility map, with all components shown.
368
      $this->visibilityMap = $this->pageMap;
369
      array_walk_recursive($this->visibilityMap, function (&$status) {
370
        $status = WebformConditionals::componentShown;
371
      });
372
      // Create empty required, set, and markup maps.
373
      $this->requiredMap = array_fill(1, count($this->pageMap), array());
374
      $this->setMap = $this->requiredMap;
375
      $this->markupMap = $this->requiredMap;
376
    }
377
    else {
378
      array_walk($this->visibilityMap[$page_num], function (&$status) {
379
        $status = WebformConditionals::componentShown;
380
      });
381
      $this->requiredMap[$page_num] = array();
382
      $this->setMap[$page_num] = array();
383
      $this->markupMap[$page_num] = array();
384
    }
385

    
386
    module_load_include('inc', 'webform', 'includes/webform.conditionals');
387

    
388
    $components = $this->node->webform['components'];
389
    $conditionals = $this->node->webform['conditionals'];
390
    $operators = webform_conditional_operators();
391
    $targetLocked = array();
392

    
393
    $first_page = $page_num ? $page_num : 1;
394
    $last_page = $page_num ? $page_num : count($this->topologicalOrder);
395
    for ($page = $first_page; $page <= $last_page; $page++) {
396
      foreach ($this->topologicalOrder[$page] as $conditional_spec) {
397

    
398
        $conditional = $conditionals[$conditional_spec['rgid']];
399
        $source_page_nums = array();
400

    
401
        // Execute each comparison callback.
402
        $this->executionStackInitialize($conditional['andor']);
403
        foreach ($conditional['rules'] as $rule) {
404
          switch ($rule['source_type']) {
405
            case 'component':
406
              $source_component = $components[$rule['source']];
407
              $source_cid = $source_component['cid'];
408

    
409
              $source_values = array();
410
              if (isset($input_values[$source_cid])) {
411
                $component_value = $input_values[$source_cid];
412
                // For select_or_other components, use only the select values because $source_values must not be a nested array.
413
                // During preview, the array is already flattened.
414
                if ($source_component['type'] === 'select' &&
415
                    !empty($source_component['extra']['other_option']) &&
416
                    isset($component_value['select'])) {
417
                  $component_value = $component_value['select'];
418
                }
419
                $source_values = is_array($component_value) ? $component_value : array($component_value);
420
              }
421

    
422
              // Determine the operator and callback.
423
              $conditional_type = webform_component_property($source_component['type'], 'conditional_type');
424
              $operator_info = $operators[$conditional_type];
425

    
426
              // Perform the comparison callback and build the results for this group.
427
              $comparison_callback = $operator_info[$rule['operator']]['comparison callback'];
428
              // Contrib caching, such as entitycache, may have loaded the node
429
              // without building it. It is possible that the component include file
430
              // hasn't been included yet. See #2529246.
431
              webform_component_include($source_component['type']);
432

    
433
              // Load missing include files for conditional types.
434
              // In the case of the 'string', 'date', and 'time' conditional types, it is
435
              // not necessary to load their include files for conditional behavior
436
              // because the required functions are already loaded
437
              // in webform.conditionals.inc.
438
              switch ($conditional_type) {
439
                case 'numeric':
440
                  module_load_include('inc', 'webform', 'components/number');
441
                  break;
442

    
443
                case 'select':
444
                  module_load_include('inc', 'webform', 'components/select');
445
                  break;
446
              }
447

    
448
              $this->executionStackAccumulate($comparison_callback($source_values, $rule['value'], $source_component));
449

    
450
              // Record page number to later determine any intra-page dependency on this source.
451
              $source_page_nums[$source_component['page_num']] = $source_component['page_num'];
452
              break;
453

    
454
            case 'conditional_start':
455
              $this->executionStackPush($rule['operator']);
456
              break;
457

    
458
            case 'conditional_end':
459
              $this->executionStackAccumulate($this->executionStackPop());
460
              break;
461
          }
462
        }
463
        $conditional_result = $this->executionStackPop();
464

    
465
        foreach ($conditional['actions'] as $action) {
466
          $action_result = $action['invert'] ? !$conditional_result : $conditional_result;
467
          $target = $action['target'];
468
          $page_num = $components[$target]['page_num'];
469
          switch ($action['action']) {
470
            case 'show':
471
              if (!$action_result) {
472
                $this->visibilityMap[$page_num][$target] = in_array($page_num, $source_page_nums) ? self::componentDependent : self::componentHidden;
473
                $this->deleteFamily($input_values, $target, $this->visibilityMap[$page_num]);
474
                $targetLocked[$target] = TRUE;
475
              }
476
              break;
477

    
478
            case 'require':
479
              $this->requiredMap[$page_num][$target] = $action_result;
480
              break;
481

    
482
            case 'set':
483
              if ($components[$target]['type'] == 'markup') {
484
                $this->markupMap[$page_num][$target] = FALSE;
485
              }
486
              if ($action_result && empty($targetLocked[$target])) {
487
                if ($components[$target]['type'] == 'markup') {
488
                  $this->markupMap[$page_num][$target] = $action['argument'];
489
                }
490
                else {
491
                  $input_values[$target] = isset($input_values[$target]) && is_array($input_values[$target])
492
                                              ? array($action['argument'])
493
                                              : $action['argument'];
494
                  $this->setMap[$page_num][$target] = TRUE;
495
                }
496
              }
497
              break;
498
          }
499
        }
500

    
501
      } // End conditinal loop
502
    } // End page loop
503

    
504
    return $input_values;
505
  }
506

    
507
  /**
508
   * Returns whether the conditionals have been executed yet.
509
   */
510
  public function isExecuted() {
511
    return (boolean) ($this->visibilityMap);
512
  }
513

    
514
  /**
515
   * Returns the required status for a component.
516
   *
517
   * Returns whether a given component is always hidden, always shown, or might
518
   * be shown depending upon other sources on the same page.
519
   *
520
   * Assumes that the conditionals have already been executed on the given page.
521
   *
522
   * @param int $cid
523
   *   The component id of the component whose visibility is being sought.
524
   * @param int $page_num
525
   *   The page number that the component is on.
526
   *
527
   * @return int
528
   *   self::componentHidden, ...Shown, or ...Dependent.
529
   */
530
  public function componentVisibility($cid, $page_num) {
531
    if (!$this->visibilityMap) {
532
      // The conditionals have not yet been executed on a submission.
533
      $this->executeConditionals(array(), 0);
534
      watchdog('webform', 'WebformConditionals::componentVisibility called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
535
    }
536
    return isset($this->visibilityMap[$page_num][$cid]) ? $this->visibilityMap[$page_num][$cid] : self::componentShown;
537
  }
538

    
539
  /**
540
   * Returns whether a given page should be displayed.
541
   *
542
   * This requires any conditional for the page itself to be shown, plus at
543
   * least one component within the page must be shown too. The first and
544
   * preview pages are always shown, however.
545
   *
546
   * @param int $page_num
547
   *   The page number that the component is on.
548
   *
549
   * @return int
550
   *   self::componentHidden or  ...Shown.
551
   */
552
  public function pageVisibility($page_num) {
553
    $result = self::componentHidden;
554
    if ($page_num == 1 || empty($this->visibilityMap[$page_num])) {
555
      $result = self::componentShown;
556
    }
557
    elseif (($page_map = $this->pageMap[$page_num]) && $this->componentVisibility(reset($page_map), $page_num)) {
558
      while ($cid = next($page_map)) {
559
        if ($this->componentVisibility($cid, $page_num) != self::componentHidden) {
560
          $result = self::componentShown;
561
          break;
562
        }
563
      }
564
    }
565
    return $result;
566
  }
567

    
568
  /**
569
   * Returns the required status for a component.
570
   *
571
   * Returns whether a given component is always required, always optional, or
572
   * unchanged by conditional logic.
573
   *
574
   * Assumes that the conditionals have already been executed on the given page.
575
   *
576
   * @param int $cid
577
   *   The component id of the component whose required state is being sought.
578
   * @param int $page_num
579
   *   The page number that the component is on.
580
   *
581
   * @return bool
582
   *   Whether the component is required based on conditionals.
583
   */
584
  public function componentRequired($cid, $page_num) {
585
    if (!$this->requiredMap) {
586
      // The conditionals have not yet been executed on a submission.
587
      $this->executeConditionals(array(), 0);
588
      watchdog('webform', 'WebformConditionals::componentRequired called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
589
    }
590
    return isset($this->requiredMap[$page_num][$cid]) ? $this->requiredMap[$page_num][$cid] : NULL;
591
  }
592

    
593
  /**
594
   * Returns whether a given component has been set by conditional logic.
595
   *
596
   * Assumes that the conditionals have already been executed on the given page.
597
   *
598
   * @param int $cid
599
   *   The component id of the component whose set state is being sought.
600
   * @param int $page_num
601
   *   The page number that the component is on.
602
   *
603
   * @return bool
604
   *   Whether the component was set based on conditionals.
605
   */
606
  public function componentSet($cid, $page_num) {
607
    if (!$this->setMap) {
608
      // The conditionals have not yet been executed on a submission.
609
      $this->executeConditionals(array(), 0);
610
      watchdog('webform', 'WebformConditionals::componentSet called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
611
    }
612
    return isset($this->setMap[$page_num][$cid]) ? $this->setMap[$page_num][$cid] : NULL;
613
  }
614

    
615
  /**
616
   * Returns the calculated markup as set by conditional logic.
617
   *
618
   * Assumes that the conditionals have already been executed on the given page.
619
   *
620
   * @param int $cid
621
   *   The component id of the component whose set state is being sought.
622
   * @param int $page_num
623
   *   The page number that the component is on.
624
   *
625
   * @return string
626
   *   The conditional markup, or NULL if none.
627
   */
628
  public function componentMarkup($cid, $page_num) {
629
    if (!$this->markupMap) {
630
      // The conditionals have not yet been executed on a submission.
631
      $this->executeConditionals(array(), 0);
632
      watchdog('webform', 'WebformConditionals::componentMarkup called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
633
    }
634
    return isset($this->markupMap[$page_num][$cid]) ? $this->markupMap[$page_num][$cid] : NULL;
635
  }
636

    
637
}