123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637 |
- <?php
- /**
- * @file
- * Conditional engine to process dependencies within the webform's conditionals.
- */
- /**
- * Performs analysis and topological sorting on the conditionals.
- */
- class WebformConditionals {
- /**
- * Define constants.
- *
- * Define constants for the result of an analysis of the conditionals on a
- * page for a given set of input values. Determines whether the component is
- * always hidden, always shown, or may or may not be shown depending upon
- * other values on the same page. In the last case, the component needs to be
- * rendered on the page because at least one source component is on the same
- * page. The field will be hidden with JavaScript.
- *
- * @var int
- */
- const componentHidden = 0;
- const componentShown = 1;
- const componentDependent = 2;
- protected static $conditionals = array();
- protected $node;
- protected $topologicalOrder;
- protected $pageMap;
- protected $childrenMap;
- protected $visibilityMap;
- protected $requiredMap;
- protected $setMap;
- protected $markupMap;
- public $errors;
- /**
- * Creates and caches a WebformConditional for a given node.
- */
- public static function factory($node) {
- if (!isset(self::$conditionals[$node->nid])) {
- self::$conditionals[$node->nid] = new WebformConditionals($node);
- }
- return self::$conditionals[$node->nid];
- }
- /**
- * Constructs a WebformConditional.
- */
- public function __construct($node) {
- $this->node = $node;
- }
- /**
- * Sorts the conditionals into topological order.
- *
- * The "nodes" of the list are the conditionals, not the components that
- * they operate upon.
- *
- * The webform components must already be sorted into component tree order
- * before calling this method.
- *
- * See http://en.wikipedia.org/wiki/Topological_sorting
- */
- protected function topologicalSort() {
- $components = $this->node->webform['components'];
- $conditionals = $this->node->webform['conditionals'];
- $errors = array();
- // Generate a component to conditional map for conditional targets.
- $cid_to_target_rgid = array();
- $cid_hidden = array();
- foreach ($conditionals as $rgid => $conditional) {
- foreach ($conditional['actions'] as $aid => $action) {
- $target_id = $action['target'];
- $cid_to_target_rgid[$target_id][$rgid] = $rgid;
- if ($action['action'] == 'show') {
- $cid_hidden[$target_id] = isset($cid_hidden[$target_id]) ? $cid_hidden[$target_id] + 1 : 1;
- if ($cid_hidden[$target_id] == 2) {
- $component = $components[$target_id];
- $errors[$component['page_num']][] = t('More than one conditional hides or shows component "@name".',
- array('@name' => $component['name']));
- }
- }
- }
- }
- // Generate T-Orders for each page.
- $new_entry = array('in' => array(), 'out' => array(), 'rgid' => array());
- $page_num = 0;
- // If the first component is a page break, then no component is on page 1. Create empty arrays for page 1.
- $sorted = array(1 => array());
- $page_map = array(1 => array());
- $component = reset($components);
- while ($component) {
- $cid = $component['cid'];
- // Start a new page, if needed.
- if ($component['page_num'] > $page_num) {
- $page_num = $component['page_num'];
- // Create an empty list that will contain the sorted elements.
- // This list is known as L in the literature.
- $sorted[$page_num] = array();
- // Create an empty list of dependency nodes for this page.
- $nodes = array();
- }
- // Create the pageMap as a side benefit of generating the t-sort.
- $page_map[$page_num][$cid] = $cid;
- // Process component by adding it's conditional data to a component-tree-traversal order an index of:
- // - incoming dependencies = the source components for the conditions for this target component and
- // - outgoing dependencies = components which depend upon the target component
- // Note: Surprisingly, 0 is a valid rgid, as well as a valid rid. Use -1 as a semaphore.
- if (isset($cid_to_target_rgid[$cid])) {
- // The component is the target of conditional(s)
- foreach ($cid_to_target_rgid[$cid] as $rgid) {
- $conditional = $conditionals[$rgid];
- if (!isset($nodes[$cid])) {
- $nodes[$cid] = $new_entry;
- }
- $nodes[$cid]['rgid'][$rgid] = $rgid;
- foreach ($conditional['rules'] as $rule) {
- if ($rule['source_type'] == 'component') {
- $source_id = $rule['source'];
- if (!isset($nodes[$source_id])) {
- $nodes[$source_id] = $new_entry;
- }
- $nodes[$cid]['in'][$source_id] = $source_id;
- $nodes[$source_id]['out'][$cid] = $cid;
- $source_component = $components[$source_id];
- $source_pid = $source_component['pid'];
- if ($source_pid) {
- if (!isset($nodes[$source_pid])) {
- $nodes[$source_pid] = $new_entry;
- }
- // The rule source is within a parent fieldset. Create a dependency on the parent.
- $nodes[$source_pid]['out'][$source_id] = $source_id;
- $nodes[$source_id]['in'][$source_pid] = $source_pid;
- }
- if ($source_component['page_num'] > $page_num) {
- $errors[$page_num][] = t('A forward reference from page @from, %from to %to was found.',
- array(
- '%from' => $source_component['name'],
- '@from' => $source_component['page_num'],
- '%to' => $component['name'],
- ));
- }
- elseif ($source_component['page_num'] == $page_num && $component['type'] == 'pagebreak') {
- $errors[$page_num][] = t("The page break %to can't be controlled by %from on the same page.",
- array(
- '%from' => $source_component['name'],
- '%to' => $component['name'],
- ));
- }
- }
- }
- }
- }
- // Fetch the next component, if any.
- $component = next($components);
- // Finish any previous page already processed.
- if (!$component || $component['page_num'] > $page_num) {
- // Create a set of all components which have are not dependent upon anything.
- // This list is known as S in the literature.
- $start_nodes = array();
- foreach ($nodes as $id => $n) {
- if (!$n['in']) {
- $start_nodes[] = $id;
- }
- }
- // Process the start nodes, removing each one in turn from the queue.
- while ($start_nodes) {
- $id = array_shift($start_nodes);
- // If the node represents an actual conditional, it can now be added
- // to the end of the sorted order because anything it depends upon has
- // already been calculated.
- if ($nodes[$id]['rgid']) {
- foreach ($nodes[$id]['rgid'] as $rgid) {
- $sorted[$page_num][] = array(
- 'cid' => $id,
- 'rgid' => $rgid,
- 'name' => $components[$id]['name'],
- );
- }
- }
- // Any other nodes that depend upon this node may now have their dependency
- // on this node removed, since it has now been calculated.
- foreach ($nodes[$id]['out'] as $out_id) {
- unset($nodes[$out_id]['in'][$id]);
- if (!$nodes[$out_id]['in']) {
- $start_nodes[] = $out_id;
- }
- }
- // All out-going dependencies have been handled.
- $nodes[$id]['out'] = array();
- }
- // Check for a cyclic graph (circular dependency).
- foreach ($nodes as $id => $n) {
- if ($n['in'] || $n['out']) {
- $errors[$page_num][] = t('A circular reference involving %name was found.',
- array('%name' => $components[$id]['name']));
- }
- }
- } // End finishing previous page.
- } // End component loop.
- // Create an empty page map for the preview page.
- $page_map[$page_num + 1] = array();
- $this->topologicalOrder = $sorted;
- $this->errors = $errors;
- $this->pageMap = $page_map;
- }
- /**
- * Returns the (possibly cached) topological sort order.
- */
- public function getOrder() {
- if (!$this->topologicalOrder) {
- $this->topologicalSort();
- }
- return $this->topologicalOrder;
- }
- /**
- * Returns an index of components by page number.
- */
- public function getPageMap() {
- if (!$this->pageMap) {
- $this->topologicalSort();
- }
- return $this->pageMap;
- }
- /**
- * Displays and error messages from the previously-generated sort order.
- *
- * User's who can't fix the webform are shown a single, simplified message.
- */
- public function reportErrors() {
- $this->getOrder();
- if ($this->errors) {
- if (webform_node_update_access($this->node)) {
- foreach ($this->errors as $page_num => $page_errors) {
- drupal_set_message(format_plural(count($page_errors),
- 'Conditional error on page @num:',
- 'Conditional errors on page @num:',
- array('@num' => $page_num)) .
- '<br /><ul><li>' . implode('</li><li>', $page_errors) . '</li></ul>', 'warning');
- }
- }
- else {
- drupal_set_message(t('This form is improperly configured. Contact the administrator.'), 'warning');
- }
- }
- }
- /**
- * Creates and caches a map of the children of a each component.
- *
- * Called after the component tree has been made and then flattened again.
- * Alas, the children data is removed when the tree is flattened. The
- * components are indexed by cid but in tree order. Because cid's are
- * numeric, they may not appear in IDE's or debuggers in their actual order.
- */
- public function getChildrenMap() {
- if (!$this->childrenMap) {
- $map = array();
- foreach ($this->node->webform['components'] as $cid => $component) {
- $pid = $component['pid'];
- if ($pid) {
- $map[$pid][] = $cid;
- }
- }
- $this->childrenMap = $map;
- }
- return $this->childrenMap;
- }
- /**
- * Deletes the value of the given component, plus any descendants.
- */
- protected function deleteFamily(&$input_values, $parent_id, &$page_visiblity_page) {
- if (isset($input_values[$parent_id])) {
- $input_values[$parent_id] = NULL;
- }
- if (isset($this->childrenMap[$parent_id])) {
- foreach ($this->childrenMap[$parent_id] as $child_id) {
- $page_visiblity_page[$child_id] = $page_visiblity_page[$parent_id];
- $this->deleteFamily($input_values, $child_id, $page_visiblity_page);
- }
- }
- }
- protected $stackPointer;
- protected $resultStack;
- /**
- * Initializes an execution stack for a conditional group's rules.
- *
- * Also initializes sub-conditional rules.
- */
- public function executionStackInitialize($andor) {
- $this->stackPointer = -1;
- $this->resultStack = array();
- $this->executionStackPush($andor);
- }
- /**
- * Starts a new subconditional for the given and/or operator.
- */
- public function executionStackPush($andor) {
- $this->resultStack[++$this->stackPointer] = array(
- 'results' => array(),
- 'andor' => $andor,
- );
- }
- /**
- * Adds a rule's result to the current sub-conditional.
- */
- public function executionStackAccumulate($result) {
- $this->resultStack[$this->stackPointer]['results'][] = $result;
- }
- /**
- * Finishes a sub-conditional and adds the result to the parent stack frame.
- */
- public function executionStackPop() {
- // Calculate the and/or result.
- $stack_frame = $this->resultStack[$this->stackPointer];
- // Pop stack and protect against stack underflow.
- $this->stackPointer = max(0, $this->stackPointer - 1);
- $conditional_results = $stack_frame['results'];
- $filtered_results = array_filter($conditional_results);
- return $stack_frame['andor'] === 'or'
- ? count($filtered_results) > 0
- : count($filtered_results) === count($conditional_results);
- }
- /**
- * Executes the conditionals on a submission.
- *
- * This removes any data which should be hidden.
- */
- public function executeConditionals($input_values, $page_num = 0) {
- $this->getOrder();
- $this->getChildrenMap();
- if (!$this->visibilityMap || $page_num == 0) {
- // Create a new visibility map, with all components shown.
- $this->visibilityMap = $this->pageMap;
- array_walk_recursive($this->visibilityMap, function (&$status) {
- $status = WebformConditionals::componentShown;
- });
- // Create empty required, set, and markup maps.
- $this->requiredMap = array_fill(1, count($this->pageMap), array());
- $this->setMap = $this->requiredMap;
- $this->markupMap = $this->requiredMap;
- }
- else {
- array_walk($this->visibilityMap[$page_num], function (&$status) {
- $status = WebformConditionals::componentShown;
- });
- $this->requiredMap[$page_num] = array();
- $this->setMap[$page_num] = array();
- $this->markupMap[$page_num] = array();
- }
- module_load_include('inc', 'webform', 'includes/webform.conditionals');
- $components = $this->node->webform['components'];
- $conditionals = $this->node->webform['conditionals'];
- $operators = webform_conditional_operators();
- $targetLocked = array();
- $first_page = $page_num ? $page_num : 1;
- $last_page = $page_num ? $page_num : count($this->topologicalOrder);
- for ($page = $first_page; $page <= $last_page; $page++) {
- foreach ($this->topologicalOrder[$page] as $conditional_spec) {
- $conditional = $conditionals[$conditional_spec['rgid']];
- $source_page_nums = array();
- // Execute each comparison callback.
- $this->executionStackInitialize($conditional['andor']);
- foreach ($conditional['rules'] as $rule) {
- switch ($rule['source_type']) {
- case 'component':
- $source_component = $components[$rule['source']];
- $source_cid = $source_component['cid'];
- $source_values = array();
- if (isset($input_values[$source_cid])) {
- $component_value = $input_values[$source_cid];
- // For select_or_other components, use only the select values because $source_values must not be a nested array.
- // During preview, the array is already flattened.
- if ($source_component['type'] === 'select' &&
- !empty($source_component['extra']['other_option']) &&
- isset($component_value['select'])) {
- $component_value = $component_value['select'];
- }
- $source_values = is_array($component_value) ? $component_value : array($component_value);
- }
- // Determine the operator and callback.
- $conditional_type = webform_component_property($source_component['type'], 'conditional_type');
- $operator_info = $operators[$conditional_type];
- // Perform the comparison callback and build the results for this group.
- $comparison_callback = $operator_info[$rule['operator']]['comparison callback'];
- // Contrib caching, such as entitycache, may have loaded the node
- // without building it. It is possible that the component include file
- // hasn't been included yet. See #2529246.
- webform_component_include($source_component['type']);
- // Load missing include files for conditional types.
- // In the case of the 'string', 'date', and 'time' conditional types, it is
- // not necessary to load their include files for conditional behavior
- // because the required functions are already loaded
- // in webform.conditionals.inc.
- switch ($conditional_type) {
- case 'numeric':
- webform_component_include('number');
- break;
- case 'select':
- webform_component_include($conditional_type);
- break;
- }
- $this->executionStackAccumulate($comparison_callback($source_values, $rule['value'], $source_component));
- // Record page number to later determine any intra-page dependency on this source.
- $source_page_nums[$source_component['page_num']] = $source_component['page_num'];
- break;
- case 'conditional_start':
- $this->executionStackPush($rule['operator']);
- break;
- case 'conditional_end':
- $this->executionStackAccumulate($this->executionStackPop());
- break;
- }
- }
- $conditional_result = $this->executionStackPop();
- foreach ($conditional['actions'] as $action) {
- $action_result = $action['invert'] ? !$conditional_result : $conditional_result;
- $target = $action['target'];
- $page_num = $components[$target]['page_num'];
- switch ($action['action']) {
- case 'show':
- if (!$action_result) {
- $this->visibilityMap[$page_num][$target] = in_array($page_num, $source_page_nums) ? self::componentDependent : self::componentHidden;
- $this->deleteFamily($input_values, $target, $this->visibilityMap[$page_num]);
- $targetLocked[$target] = TRUE;
- }
- break;
- case 'require':
- $this->requiredMap[$page_num][$target] = $action_result;
- break;
- case 'set':
- if ($components[$target]['type'] == 'markup') {
- $this->markupMap[$page_num][$target] = FALSE;
- }
- if ($action_result && empty($targetLocked[$target])) {
- if ($components[$target]['type'] == 'markup') {
- $this->markupMap[$page_num][$target] = $action['argument'];
- }
- else {
- $input_values[$target] = isset($input_values[$target]) && is_array($input_values[$target])
- ? array($action['argument'])
- : $action['argument'];
- $this->setMap[$page_num][$target] = TRUE;
- }
- }
- break;
- }
- }
- } // End conditinal loop
- } // End page loop
- return $input_values;
- }
- /**
- * Returns whether the conditionals have been executed yet.
- */
- public function isExecuted() {
- return (boolean) ($this->visibilityMap);
- }
- /**
- * Returns the required status for a component.
- *
- * Returns whether a given component is always hidden, always shown, or might
- * be shown depending upon other sources on the same page.
- *
- * Assumes that the conditionals have already been executed on the given page.
- *
- * @param int $cid
- * The component id of the component whose visibility is being sought.
- * @param int $page_num
- * The page number that the component is on.
- *
- * @return int
- * self::componentHidden, ...Shown, or ...Dependent.
- */
- public function componentVisibility($cid, $page_num) {
- if (!$this->visibilityMap) {
- // The conditionals have not yet been executed on a submission.
- $this->executeConditionals(array(), 0);
- watchdog('webform', 'WebformConditionals::componentVisibility called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
- }
- return isset($this->visibilityMap[$page_num][$cid]) ? $this->visibilityMap[$page_num][$cid] : self::componentShown;
- }
- /**
- * Returns whether a given page should be displayed.
- *
- * This requires any conditional for the page itself to be shown, plus at
- * least one component within the page must be shown too. The first and
- * preview pages are always shown, however.
- *
- * @param int $page_num
- * The page number that the component is on.
- *
- * @return int
- * self::componentHidden or ...Shown.
- */
- public function pageVisibility($page_num) {
- $result = self::componentHidden;
- if ($page_num == 1 || empty($this->visibilityMap[$page_num])) {
- $result = self::componentShown;
- }
- elseif (($page_map = $this->pageMap[$page_num]) && $this->componentVisibility(reset($page_map), $page_num)) {
- while ($cid = next($page_map)) {
- if ($this->componentVisibility($cid, $page_num) != self::componentHidden) {
- $result = self::componentShown;
- break;
- }
- }
- }
- return $result;
- }
- /**
- * Returns the required status for a component.
- *
- * Returns whether a given component is always required, always optional, or
- * unchanged by conditional logic.
- *
- * Assumes that the conditionals have already been executed on the given page.
- *
- * @param int $cid
- * The component id of the component whose required state is being sought.
- * @param int $page_num
- * The page number that the component is on.
- *
- * @return bool
- * Whether the component is required based on conditionals.
- */
- public function componentRequired($cid, $page_num) {
- if (!$this->requiredMap) {
- // The conditionals have not yet been executed on a submission.
- $this->executeConditionals(array(), 0);
- watchdog('webform', 'WebformConditionals::componentRequired called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
- }
- return isset($this->requiredMap[$page_num][$cid]) ? $this->requiredMap[$page_num][$cid] : NULL;
- }
- /**
- * Returns whether a given component has been set by conditional logic.
- *
- * Assumes that the conditionals have already been executed on the given page.
- *
- * @param int $cid
- * The component id of the component whose set state is being sought.
- * @param int $page_num
- * The page number that the component is on.
- *
- * @return bool
- * Whether the component was set based on conditionals.
- */
- public function componentSet($cid, $page_num) {
- if (!$this->setMap) {
- // The conditionals have not yet been executed on a submission.
- $this->executeConditionals(array(), 0);
- watchdog('webform', 'WebformConditionals::componentSet called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
- }
- return isset($this->setMap[$page_num][$cid]) ? $this->setMap[$page_num][$cid] : NULL;
- }
- /**
- * Returns the calculated markup as set by conditional logic.
- *
- * Assumes that the conditionals have already been executed on the given page.
- *
- * @param int $cid
- * The component id of the component whose set state is being sought.
- * @param int $page_num
- * The page number that the component is on.
- *
- * @return string
- * The conditional markup, or NULL if none.
- */
- public function componentMarkup($cid, $page_num) {
- if (!$this->markupMap) {
- // The conditionals have not yet been executed on a submission.
- $this->executeConditionals(array(), 0);
- watchdog('webform', 'WebformConditionals::componentMarkup called prior to evaluating a submission.', array(), WATCHDOG_ERROR);
- }
- return isset($this->markupMap[$page_num][$cid]) ? $this->markupMap[$page_num][$cid] : NULL;
- }
- }
|