webform.webformconditionals.inc 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. <?php
  2. /**
  3. * @file
  4. * Conditional engine to process dependencies within the webform's conditionals.
  5. */
  6. /**
  7. * Performs analysis and topological sorting on the conditionals.
  8. */
  9. class WebformConditionals {
  10. /**
  11. * Define constants.
  12. *
  13. * Define constants for the result of an analysis of the conditionals on a
  14. * page for a given set of input values. Determines whether the component is
  15. * always hidden, always shown, or may or may not be shown depending upon
  16. * other values on the same page. In the last case, the component needs to be
  17. * rendered on the page because at least one source component is on the same
  18. * page. The field will be hidden with JavaScript.
  19. *
  20. * @var int
  21. */
  22. const componentHidden = 0;
  23. const componentShown = 1;
  24. const componentDependent = 2;
  25. protected static $conditionals = array();
  26. protected $node;
  27. protected $topologicalOrder;
  28. protected $pageMap;
  29. protected $childrenMap;
  30. protected $visibilityMap;
  31. protected $requiredMap;
  32. protected $setMap;
  33. protected $markupMap;
  34. public $errors;
  35. /**
  36. * Creates and caches a WebformConditional for a given node.
  37. */
  38. public static function factory($node) {
  39. if (!isset(self::$conditionals[$node->nid])) {
  40. self::$conditionals[$node->nid] = new WebformConditionals($node);
  41. }
  42. return self::$conditionals[$node->nid];
  43. }
  44. /**
  45. * Constructs a WebformConditional.
  46. */
  47. public function __construct($node) {
  48. $this->node = $node;
  49. }
  50. /**
  51. * Sorts the conditionals into topological order.
  52. *
  53. * The "nodes" of the list are the conditionals, not the components that
  54. * they operate upon.
  55. *
  56. * The webform components must already be sorted into component tree order
  57. * before calling this method.
  58. *
  59. * See http://en.wikipedia.org/wiki/Topological_sorting
  60. */
  61. protected function topologicalSort() {
  62. $components = $this->node->webform['components'];
  63. $conditionals = $this->node->webform['conditionals'];
  64. $errors = array();
  65. // Generate a component to conditional map for conditional targets.
  66. $cid_to_target_rgid = array();
  67. $cid_hidden = array();
  68. foreach ($conditionals as $rgid => $conditional) {
  69. foreach ($conditional['actions'] as $aid => $action) {
  70. $target_id = $action['target'];
  71. $cid_to_target_rgid[$target_id][$rgid] = $rgid;
  72. if ($action['action'] == 'show') {
  73. $cid_hidden[$target_id] = isset($cid_hidden[$target_id]) ? $cid_hidden[$target_id] + 1 : 1;
  74. if ($cid_hidden[$target_id] == 2) {
  75. $component = $components[$target_id];
  76. $errors[$component['page_num']][] = t('More than one conditional hides or shows component "@name".',
  77. array('@name' => $component['name']));
  78. }
  79. }
  80. }
  81. }
  82. // Generate T-Orders for each page.
  83. $new_entry = array('in' => array(), 'out' => array(), 'rgid' => array());
  84. $page_num = 0;
  85. // If the first component is a page break, then no component is on page 1. Create empty arrays for page 1.
  86. $sorted = array(1 => array());
  87. $page_map = array(1 => array());
  88. $component = reset($components);
  89. while ($component) {
  90. $cid = $component['cid'];
  91. // Start a new page, if needed.
  92. if ($component['page_num'] > $page_num) {
  93. $page_num = $component['page_num'];
  94. // Create an empty list that will contain the sorted elements.
  95. // This list is known as L in the literature.
  96. $sorted[$page_num] = array();
  97. // Create an empty list of dependency nodes for this page.
  98. $nodes = array();
  99. }
  100. // Create the pageMap as a side benefit of generating the t-sort.
  101. $page_map[$page_num][$cid] = $cid;
  102. // Process component by adding it's conditional data to a component-tree-traversal order an index of:
  103. // - incoming dependencies = the source components for the conditions for this target component and
  104. // - outgoing dependencies = components which depend upon the target component
  105. // Note: Surprisingly, 0 is a valid rgid, as well as a valid rid. Use -1 as a semaphore.
  106. if (isset($cid_to_target_rgid[$cid])) {
  107. // The component is the target of conditional(s)
  108. foreach ($cid_to_target_rgid[$cid] as $rgid) {
  109. $conditional = $conditionals[$rgid];
  110. if (!isset($nodes[$cid])) {
  111. $nodes[$cid] = $new_entry;
  112. }
  113. $nodes[$cid]['rgid'][$rgid] = $rgid;
  114. foreach ($conditional['rules'] as $rule) {
  115. if ($rule['source_type'] == 'component') {
  116. $source_id = $rule['source'];
  117. if (!isset($nodes[$source_id])) {
  118. $nodes[$source_id] = $new_entry;
  119. }
  120. $nodes[$cid]['in'][$source_id] = $source_id;
  121. $nodes[$source_id]['out'][$cid] = $cid;
  122. $source_component = $components[$source_id];
  123. $source_pid = $source_component['pid'];
  124. if ($source_pid) {
  125. if (!isset($nodes[$source_pid])) {
  126. $nodes[$source_pid] = $new_entry;
  127. }
  128. // The rule source is within a parent fieldset. Create a dependency on the parent.
  129. $nodes[$source_pid]['out'][$source_id] = $source_id;
  130. $nodes[$source_id]['in'][$source_pid] = $source_pid;
  131. }
  132. if ($source_component['page_num'] > $page_num) {
  133. $errors[$page_num][] = t('A forward reference from page @from, %from to %to was found.',
  134. array(
  135. '%from' => $source_component['name'],
  136. '@from' => $source_component['page_num'],
  137. '%to' => $component['name'],
  138. ));
  139. }
  140. elseif ($source_component['page_num'] == $page_num && $component['type'] == 'pagebreak') {
  141. $errors[$page_num][] = t("The page break %to can't be controlled by %from on the same page.",
  142. array(
  143. '%from' => $source_component['name'],
  144. '%to' => $component['name'],
  145. ));
  146. }
  147. }
  148. }
  149. }
  150. }
  151. // Fetch the next component, if any.
  152. $component = next($components);
  153. // Finish any previous page already processed.
  154. if (!$component || $component['page_num'] > $page_num) {
  155. // Create a set of all components which have are not dependent upon anything.
  156. // This list is known as S in the literature.
  157. $start_nodes = array();
  158. foreach ($nodes as $id => $n) {
  159. if (!$n['in']) {
  160. $start_nodes[] = $id;
  161. }
  162. }
  163. // Process the start nodes, removing each one in turn from the queue.
  164. while ($start_nodes) {
  165. $id = array_shift($start_nodes);
  166. // If the node represents an actual conditional, it can now be added
  167. // to the end of the sorted order because anything it depends upon has
  168. // already been calculated.
  169. if ($nodes[$id]['rgid']) {
  170. foreach ($nodes[$id]['rgid'] as $rgid) {
  171. $sorted[$page_num][] = array(
  172. 'cid' => $id,
  173. 'rgid' => $rgid,
  174. 'name' => $components[$id]['name'],
  175. );
  176. }
  177. }
  178. // Any other nodes that depend upon this node may now have their dependency
  179. // on this node removed, since it has now been calculated.
  180. foreach ($nodes[$id]['out'] as $out_id) {
  181. unset($nodes[$out_id]['in'][$id]);
  182. if (!$nodes[$out_id]['in']) {
  183. $start_nodes[] = $out_id;
  184. }
  185. }
  186. // All out-going dependencies have been handled.
  187. $nodes[$id]['out'] = array();
  188. }
  189. // Check for a cyclic graph (circular dependency).
  190. foreach ($nodes as $id => $n) {
  191. if ($n['in'] || $n['out']) {
  192. $errors[$page_num][] = t('A circular reference involving %name was found.',
  193. array('%name' => $components[$id]['name']));
  194. }
  195. }
  196. } // End finishing previous page.
  197. } // End component loop.
  198. // Create an empty page map for the preview page.
  199. $page_map[$page_num + 1] = array();
  200. $this->topologicalOrder = $sorted;
  201. $this->errors = $errors;
  202. $this->pageMap = $page_map;
  203. }
  204. /**
  205. * Returns the (possibly cached) topological sort order.
  206. */
  207. public function getOrder() {
  208. if (!$this->topologicalOrder) {
  209. $this->topologicalSort();
  210. }
  211. return $this->topologicalOrder;
  212. }
  213. /**
  214. * Returns an index of components by page number.
  215. */
  216. public function getPageMap() {
  217. if (!$this->pageMap) {
  218. $this->topologicalSort();
  219. }
  220. return $this->pageMap;
  221. }
  222. /**
  223. * Displays and error messages from the previously-generated sort order.
  224. *
  225. * User's who can't fix the webform are shown a single, simplified message.
  226. */
  227. public function reportErrors() {
  228. $this->getOrder();
  229. if ($this->errors) {
  230. if (webform_node_update_access($this->node)) {
  231. foreach ($this->errors as $page_num => $page_errors) {
  232. drupal_set_message(format_plural(count($page_errors),
  233. 'Conditional error on page @num:',
  234. 'Conditional errors on page @num:',
  235. array('@num' => $page_num)) .
  236. '<br /><ul><li>' . implode('</li><li>', $page_errors) . '</li></ul>', 'warning');
  237. }
  238. }
  239. else {
  240. drupal_set_message(t('This form is improperly configured. Contact the administrator.'), 'warning');
  241. }
  242. }
  243. }
  244. /**
  245. * Creates and caches a map of the children of a each component.
  246. *
  247. * Called after the component tree has been made and then flattened again.
  248. * Alas, the children data is removed when the tree is flattened. The
  249. * components are indexed by cid but in tree order. Because cid's are
  250. * numeric, they may not appear in IDE's or debuggers in their actual order.
  251. */
  252. public function getChildrenMap() {
  253. if (!$this->childrenMap) {
  254. $map = array();
  255. foreach ($this->node->webform['components'] as $cid => $component) {
  256. $pid = $component['pid'];
  257. if ($pid) {
  258. $map[$pid][] = $cid;
  259. }
  260. }
  261. $this->childrenMap = $map;
  262. }
  263. return $this->childrenMap;
  264. }
  265. /**
  266. * Deletes the value of the given component, plus any descendants.
  267. */
  268. protected function deleteFamily(&$input_values, $parent_id, &$page_visiblity_page) {
  269. if (isset($input_values[$parent_id])) {
  270. $input_values[$parent_id] = NULL;
  271. }
  272. if (isset($this->childrenMap[$parent_id])) {
  273. foreach ($this->childrenMap[$parent_id] as $child_id) {
  274. $page_visiblity_page[$child_id] = $page_visiblity_page[$parent_id];
  275. $this->deleteFamily($input_values, $child_id, $page_visiblity_page);
  276. }
  277. }
  278. }
  279. protected $stackPointer;
  280. protected $resultStack;
  281. /**
  282. * Initializes an execution stack for a conditional group's rules.
  283. *
  284. * Also initializes sub-conditional rules.
  285. */
  286. public function executionStackInitialize($andor) {
  287. $this->stackPointer = -1;
  288. $this->resultStack = array();
  289. $this->executionStackPush($andor);
  290. }
  291. /**
  292. * Starts a new subconditional for the given and/or operator.
  293. */
  294. public function executionStackPush($andor) {
  295. $this->resultStack[++$this->stackPointer] = array(
  296. 'results' => array(),
  297. 'andor' => $andor,
  298. );
  299. }
  300. /**
  301. * Adds a rule's result to the current sub-conditional.
  302. */
  303. public function executionStackAccumulate($result) {
  304. $this->resultStack[$this->stackPointer]['results'][] = $result;
  305. }
  306. /**
  307. * Finishes a sub-conditional and adds the result to the parent stack frame.
  308. */
  309. public function executionStackPop() {
  310. // Calculate the and/or result.
  311. $stack_frame = $this->resultStack[$this->stackPointer];
  312. // Pop stack and protect against stack underflow.
  313. $this->stackPointer = max(0, $this->stackPointer - 1);
  314. $conditional_results = $stack_frame['results'];
  315. $filtered_results = array_filter($conditional_results);
  316. return $stack_frame['andor'] === 'or'
  317. ? count($filtered_results) > 0
  318. : count($filtered_results) === count($conditional_results);
  319. }
  320. /**
  321. * Executes the conditionals on a submission.
  322. *
  323. * This removes any data which should be hidden.
  324. */
  325. public function executeConditionals($input_values, $page_num = 0) {
  326. $this->getOrder();
  327. $this->getChildrenMap();
  328. if (!$this->visibilityMap || $page_num == 0) {
  329. // Create a new visibility map, with all components shown.
  330. $this->visibilityMap = $this->pageMap;
  331. array_walk_recursive($this->visibilityMap, function (&$status) {
  332. $status = WebformConditionals::componentShown;
  333. });
  334. // Create empty required, set, and markup maps.
  335. $this->requiredMap = array_fill(1, count($this->pageMap), array());
  336. $this->setMap = $this->requiredMap;
  337. $this->markupMap = $this->requiredMap;
  338. }
  339. else {
  340. array_walk($this->visibilityMap[$page_num], function (&$status) {
  341. $status = WebformConditionals::componentShown;
  342. });
  343. $this->requiredMap[$page_num] = array();
  344. $this->setMap[$page_num] = array();
  345. $this->markupMap[$page_num] = array();
  346. }
  347. module_load_include('inc', 'webform', 'includes/webform.conditionals');
  348. $components = $this->node->webform['components'];
  349. $conditionals = $this->node->webform['conditionals'];
  350. $operators = webform_conditional_operators();
  351. $targetLocked = array();
  352. $first_page = $page_num ? $page_num : 1;
  353. $last_page = $page_num ? $page_num : count($this->topologicalOrder);
  354. for ($page = $first_page; $page <= $last_page; $page++) {
  355. foreach ($this->topologicalOrder[$page] as $conditional_spec) {
  356. $conditional = $conditionals[$conditional_spec['rgid']];
  357. $source_page_nums = array();
  358. // Execute each comparison callback.
  359. $this->executionStackInitialize($conditional['andor']);
  360. foreach ($conditional['rules'] as $rule) {
  361. switch ($rule['source_type']) {
  362. case 'component':
  363. $source_component = $components[$rule['source']];
  364. $source_cid = $source_component['cid'];
  365. $source_values = array();
  366. if (isset($input_values[$source_cid])) {
  367. $component_value = $input_values[$source_cid];
  368. // For select_or_other components, use only the select values because $source_values must not be a nested array.
  369. // During preview, the array is already flattened.
  370. if ($source_component['type'] === 'select' &&
  371. !empty($source_component['extra']['other_option']) &&
  372. isset($component_value['select'])) {
  373. $component_value = $component_value['select'];
  374. }
  375. $source_values = is_array($component_value) ? $component_value : array($component_value);
  376. }
  377. // Determine the operator and callback.
  378. $conditional_type = webform_component_property($source_component['type'], 'conditional_type');
  379. $operator_info = $operators[$conditional_type];
  380. // Perform the comparison callback and build the results for this group.
  381. $comparison_callback = $operator_info[$rule['operator']]['comparison callback'];
  382. // Contrib caching, such as entitycache, may have loaded the node
  383. // without building it. It is possible that the component include file
  384. // hasn't been included yet. See #2529246.
  385. webform_component_include($source_component['type']);
  386. // Load missing include files for conditional types.
  387. // In the case of the 'string', 'date', and 'time' conditional types, it is
  388. // not necessary to load their include files for conditional behavior
  389. // because the required functions are already loaded
  390. // in webform.conditionals.inc.
  391. switch ($conditional_type) {
  392. case 'numeric':
  393. webform_component_include('number');
  394. break;
  395. case 'select':
  396. webform_component_include($conditional_type);
  397. break;
  398. }
  399. $this->executionStackAccumulate($comparison_callback($source_values, $rule['value'], $source_component));
  400. // Record page number to later determine any intra-page dependency on this source.
  401. $source_page_nums[$source_component['page_num']] = $source_component['page_num'];
  402. break;
  403. case 'conditional_start':
  404. $this->executionStackPush($rule['operator']);
  405. break;
  406. case 'conditional_end':
  407. $this->executionStackAccumulate($this->executionStackPop());
  408. break;
  409. }
  410. }
  411. $conditional_result = $this->executionStackPop();
  412. foreach ($conditional['actions'] as $action) {
  413. $action_result = $action['invert'] ? !$conditional_result : $conditional_result;
  414. $target = $action['target'];
  415. $page_num = $components[$target]['page_num'];
  416. switch ($action['action']) {
  417. case 'show':
  418. if (!$action_result) {
  419. $this->visibilityMap[$page_num][$target] = in_array($page_num, $source_page_nums) ? self::componentDependent : self::componentHidden;
  420. $this->deleteFamily($input_values, $target, $this->visibilityMap[$page_num]);
  421. $targetLocked[$target] = TRUE;
  422. }
  423. break;
  424. case 'require':
  425. $this->requiredMap[$page_num][$target] = $action_result;
  426. break;
  427. case 'set':
  428. if ($components[$target]['type'] == 'markup') {
  429. $this->markupMap[$page_num][$target] = FALSE;
  430. }
  431. if ($action_result && empty($targetLocked[$target])) {
  432. if ($components[$target]['type'] == 'markup') {
  433. $this->markupMap[$page_num][$target] = $action['argument'];
  434. }
  435. else {
  436. $input_values[$target] = isset($input_values[$target]) && is_array($input_values[$target])
  437. ? array($action['argument'])
  438. : $action['argument'];
  439. $this->setMap[$page_num][$target] = TRUE;
  440. }
  441. }
  442. break;
  443. }
  444. }
  445. } // End conditinal loop
  446. } // End page loop
  447. return $input_values;
  448. }
  449. /**
  450. * Returns whether the conditionals have been executed yet.
  451. */
  452. public function isExecuted() {
  453. return (boolean) ($this->visibilityMap);
  454. }
  455. /**
  456. * Returns the required status for a component.
  457. *
  458. * Returns whether a given component is always hidden, always shown, or might
  459. * be shown depending upon other sources on the same page.
  460. *
  461. * Assumes that the conditionals have already been executed on the given page.
  462. *
  463. * @param int $cid
  464. * The component id of the component whose visibility is being sought.
  465. * @param int $page_num
  466. * The page number that the component is on.
  467. *
  468. * @return int
  469. * self::componentHidden, ...Shown, or ...Dependent.
  470. */
  471. public function componentVisibility($cid, $page_num) {
  472. if (!$this->visibilityMap) {
  473. // The conditionals have not yet been executed on a submission.
  474. $this->executeConditionals(array(), 0);
  475. watchdog('webform', 'WebformConditionals::componentVisibility called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
  476. }
  477. return isset($this->visibilityMap[$page_num][$cid]) ? $this->visibilityMap[$page_num][$cid] : self::componentShown;
  478. }
  479. /**
  480. * Returns whether a given page should be displayed.
  481. *
  482. * This requires any conditional for the page itself to be shown, plus at
  483. * least one component within the page must be shown too. The first and
  484. * preview pages are always shown, however.
  485. *
  486. * @param int $page_num
  487. * The page number that the component is on.
  488. *
  489. * @return int
  490. * self::componentHidden or ...Shown.
  491. */
  492. public function pageVisibility($page_num) {
  493. $result = self::componentHidden;
  494. if ($page_num == 1 || empty($this->visibilityMap[$page_num])) {
  495. $result = self::componentShown;
  496. }
  497. elseif (($page_map = $this->pageMap[$page_num]) && $this->componentVisibility(reset($page_map), $page_num)) {
  498. while ($cid = next($page_map)) {
  499. if ($this->componentVisibility($cid, $page_num) != self::componentHidden) {
  500. $result = self::componentShown;
  501. break;
  502. }
  503. }
  504. }
  505. return $result;
  506. }
  507. /**
  508. * Returns the required status for a component.
  509. *
  510. * Returns whether a given component is always required, always optional, or
  511. * unchanged by conditional logic.
  512. *
  513. * Assumes that the conditionals have already been executed on the given page.
  514. *
  515. * @param int $cid
  516. * The component id of the component whose required state is being sought.
  517. * @param int $page_num
  518. * The page number that the component is on.
  519. *
  520. * @return bool
  521. * Whether the component is required based on conditionals.
  522. */
  523. public function componentRequired($cid, $page_num) {
  524. if (!$this->requiredMap) {
  525. // The conditionals have not yet been executed on a submission.
  526. $this->executeConditionals(array(), 0);
  527. watchdog('webform', 'WebformConditionals::componentRequired called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
  528. }
  529. return isset($this->requiredMap[$page_num][$cid]) ? $this->requiredMap[$page_num][$cid] : NULL;
  530. }
  531. /**
  532. * Returns whether a given component has been set by conditional logic.
  533. *
  534. * Assumes that the conditionals have already been executed on the given page.
  535. *
  536. * @param int $cid
  537. * The component id of the component whose set state is being sought.
  538. * @param int $page_num
  539. * The page number that the component is on.
  540. *
  541. * @return bool
  542. * Whether the component was set based on conditionals.
  543. */
  544. public function componentSet($cid, $page_num) {
  545. if (!$this->setMap) {
  546. // The conditionals have not yet been executed on a submission.
  547. $this->executeConditionals(array(), 0);
  548. watchdog('webform', 'WebformConditionals::componentSet called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
  549. }
  550. return isset($this->setMap[$page_num][$cid]) ? $this->setMap[$page_num][$cid] : NULL;
  551. }
  552. /**
  553. * Returns the calculated markup as set by conditional logic.
  554. *
  555. * Assumes that the conditionals have already been executed on the given page.
  556. *
  557. * @param int $cid
  558. * The component id of the component whose set state is being sought.
  559. * @param int $page_num
  560. * The page number that the component is on.
  561. *
  562. * @return string
  563. * The conditional markup, or NULL if none.
  564. */
  565. public function componentMarkup($cid, $page_num) {
  566. if (!$this->markupMap) {
  567. // The conditionals have not yet been executed on a submission.
  568. $this->executeConditionals(array(), 0);
  569. watchdog('webform', 'WebformConditionals::componentMarkup called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
  570. }
  571. return isset($this->markupMap[$page_num][$cid]) ? $this->markupMap[$page_num][$cid] : NULL;
  572. }
  573. }