Issues related to the value of a component hidden by a conditional.
Planned solution
Because conditionals can now trigger other conditionals by virtue of being show/hidden (including the contents of fieldsets), the conditionals but now be treated as a Directed Acyclic Graph, which must be executed in topological sorting order. This substantially complicates the feature.
- Generate a topological sort order for each page, providing for fieldsets and page breaks.
- Detect unsupported configurations of a) circular references and b) "forward" references where a component depends upon a component on a subsequent page. In this case, issue a warning and use the user-defined order of the conditionals as the topological sort order.
- Send the page's topological sort order to the browser. Continue to send source map which maps a component to those components which depend upon it.
- Instead of initially triggering change events for every entry in the source map, run through the conditionals in the t-sort order.
- Upon a change in a source field, run through all the conditionals in t-sort order.
- When a page is submitted, execute the page's conditionals in t-sort order.
- When checking for skipped pages, execute the page's conditionals in t-sort order, discarding any values (in database or $form_state['storage'].
Issue to address:
- Currently values hidden by a conditional may end up being saved in the database, where they can be shown in views (including the results table) and/or downloaded. This is the original reported issue.
- #2447745: Value of a hidden field should be empty in browser
- Conditional values that are hidden by javascript are not considered empty, but are (or should be) considered empty by the server when the pages is submitted.
-By triggering change events in the browser when fields are shown and hidden, the possibility of an infinite loop is created if there are circular dependencies. - Confirm that the server considers hidden conditional values as empty, if fix if not.
- By triggering change events in the server when fields are shown and hidden, the possibility of an infinite loop is created if there are circular dependencies.
- Prevent or warn if there are backward conditionals (which show/hide elements in previous page(s).
- Prevent or warn if there are circular dependencies in the conditionals.
- Ensure that data in any component which is excluded from the page because it is hidden based on components on previous pages is deleted when the page is submitted. This includes both drafts/submission and data stored in $form_state['storage'].
Steps to reproduce
- Create a 2-page form. On page 2, put a component that is shown conditionally based on the value of a field on page 1. Set webform to save draft when switching pages.
- Enter a value on page 1 that causes the page 2 component to show.
- Go to next page.
- Type a value in the component.
- To to previous page.
- Change the page 1 value so that the page 2 component will be hidden.
- Submit the form.
- Expected result: The page 2 component submits a NULL value, as if it had always been empty.
- Actual result: The value entered into the page 2 component is submitted.
This results in nonsensical form submissions. It really should be that fields that were hidden are blank.
Background
In #1766108: Reset values when hiding conditional fields (Conditional conflicts in a complex 3 level Webform) quicksketch wrote:
Ironically we just removed the "clear field values when hidden" behavior in #1677468: Non-text conditional component values deleted when hidden
Because of this, I (Liam Morland) wanted to come to consensus about how it should work before writing a patch.
Committed to 7.x-4.x.
| Comment | File | Size | Author |
|---|---|---|---|
| #31 | webform-meta_conditionals-1840776-30.patch | 31.74 KB | danchadwick |
| #27 | webform-meta_conditionals-1840776-27.patch | 30.42 KB | danchadwick |
| #17 | webform-meta_conditionals-1840776-17.patch | 30.08 KB | danchadwick |
Comments
Comment #1
danchadwick commentedAny update on this? Does this still happen, and is it related strictly to drafts?
Comment #2
liam morlandThe problem still happens and happens whether or not drafts are being saved.
When viewing submissions, such as on the preview page and the results view submission page, the hidden value does not show up. But it does show up in the results table and when downloading results.
Comment #3
danchadwick commentedHmmmm. I agree that it is not totally clear what should happen here. I'm going to mark this as postponed until there is come consensus. I could see:
Comment #4
liam morlandI like #3. To clarify: When a submission is finalized or a finalized submission is edited, but not when a draft is saved.
Comment #5
danchadwick commentedComment #6
danchadwick commentedI looked into this a bit further. The situation is not as discussed in #3. Values which are hidden by conditions are always intended to be empty (i.e. not stored in database). In the particular situation originally described, what happened was:
1) The draft was saved with a value on page 2.
2) The user returned to page one and changed a value which would hide the value.
3) Upon returning to page 2, the field was not sent to the browser at all.
4) When the form is submitted, the value is left in the database.
One possibility would be when a form is saved (submitted, auto-save-on-prev/next, auto-save-on-validate) to recursively find any data in the database that should be hidden based upon source components on the page and delete them. In this example, when the user returned to the first page, changed the value, and then left again, that would trigger the data to be deleted.
A simpler test would be to send components that are completely hidden from the page to the browser anyhow, as empty hidden HTML. This would cause the data to be deleted later when submitted. This would not "fix" data that was hidden by backward reference -- for example, the component on page 2 hides the item on page 1. This is a rather degenerate case and is perhaps not worth supporting.
Comment #7
danchadwick commentedI am expanding the scope of this issue.
When conditionals are evaluated by jQuery, hidden components are not treated as empty, but they are (I think) when evaluated by the server. For example
Text field = 'hi' hides Number field
Number field = 1 hides Date field
Put 1 in Number field to hide date field, then put 'hi' into text field to hide number field. The date field should reappear since it should be considered empty.
Now imagine that Number field resides in a page prior to Text and Date. You would ideally like the same behavior.
We should perhaps put all dependent (recursively) components into hidden fields and send them to the browser. This would solve the problem of removing hidden data on other pages (including previous pages), as well as create DOM elements to trigger jQuery.
The good news is that hiding is equivalent to changing the data to empty, we are laying the groundwork for conditionals to set values.
Comment #8
danchadwick commentedA possible optimization:
If a node in the dependency graph plus all of its descendants are all for components that reside on a page AFTER the current page, then they don't need to be sent to the browser. However, this also means that when webform skips over entirely blank pages, it needs to remove the data on those pages from the database or form_state['storage'] because they would never be sent to the browser.
Boy, conditionals and multiple pages interact in unfortunate ways.
Comment #9
danchadwick commentedLet's step back from the abyss. I don't see a reason to support backward conditionals -- conditionals that change a component on a previous page. This simplifies things quite a bit, but we have to add a warning for this condition.
Updated issue summary.
Comment #10
danchadwick commentedComment #11
danchadwick commentedComment #12
danchadwick commentedComment #13
danchadwick commentedA hideously large, but cohesive patch.
Theory of Operation
When a webform node is loaded, a WebformConditionals object is added to the node as $node->webform[conditional_sorter]. The sorter initially does nothing, but when called upon, analyzes the conditionals to create a topological sort order, which provides the correct order of execution of the conditionals. This sort order is grouped by page. Additionally, a page map is created to help determine which page a particular component is on, and a list of errors found. All data is cached. When the webform_client_form is cached, so is the sorter, avoid the need to re-analyze the dependencies for each page of a multi-page form.
The sorter can also be used to evaluate the conditionals on a given submission to ensure that the server is making the same decisions as the browser. This also provides conditional operation in the absence of JavaScript.
webform.conditionals.inc
webform.webformconditionals.inc
This file contains the WebformConditionals class and nothing else. The methods are:
webform.js
The JQuery that runs was changes, removing the re-triggering logic added in the related issue and replacing it with execution of all the conditionals in topological order. This includes the initial execution that happens upon page load. The DOM is no longer used for retriggering.
webform.info
New class was added.
webform.module
Additional possible tasks.
This is a large, fairly complicated patch that needs solid review.
Comment #15
liam morlandWow, that is big! I won't be able to help with this until later in March. I am taking my Scouting troop to Bermuda, leaving on Sunday.
Comment #16
danchadwick commentedTest fails because the testbot's class registry is stale. Failure is unrelated to this patch. I'm not sure if there is a way to change the status to Needs Review without starting the testbot.
Comment #17
danchadwick commentedMy error. Patch was missing the new file. I still think testbot's class registry is going to be stale though.
Comment #18
danchadwick commentedI may remove the singleton code from the factory. I don't think it's currently used because whenever I needed the sorter, I already had the loaded node.
Comment #19
quicksketchThanks for taking this on Dan. This is going to take a while for me to totally understand what's happening inside the new class. I can only comment on the general approach to implementation at first pass. A few thoughts:
- The new WebformConditionals class is essentially a "controller", in that it generally does a lot of actions rather than being a data-store of some kind. I'm generally not a fan of controller-like classes in the first place, but in situations where it can help with clarity (such as our existing Exporters) I try not to begrudge them too much. If it's possible to simplify this in a procedural approach, I'd find that preferable.
- Drupal itself already contains an acyclic graph search function (in includes/graph.inc, it's used to ensure dependencies are met when installing/uninstalling modules). It's quite a bit less comprehensive than this class, but could it be used to simplify our approach?
- If the WebformConditionals class is indeed a controller, it shouldn't be bundled into the $node object. The $node object should be a container of data, not objects that perform actions on itself. I'd prefer to see something like a
webform_get_conditional_sorter($node)function that statically stored these classes if they're going to be used repeatedly.So instead of this:
We would get the sorter separately:
That would keep our "controller" (the sorter) functionality separate from our data (the $node). Importantly for Drupal users, it wouldn't pollute the $node data with a bunch of internal, temporary, and recursive information when trying to print_r() or debug() the $node variable.
- Let's please rename "execute" method using a verb that describes what it's doing. Without reading the docblock, I would have no idea what the execution of a "WebformConditional" would do. Execute reminds me of the Kingdom of Nouns.
Comment #20
danchadwick commentedWut, wait, the patch passed testbot! I wasn't expecting it to be able to cope with the stale registry. Cool.
Thanks for the excellent comments, quicksketch.
- I think this is stronger as a class, especially since its methods share data and the protected methods guarantee that the data isn't written to or accessed when it hasn't been calculated yet. Because of the convoluted way forms get loaded, it is beneficial to put the controller in the $node. This avoids having to recalculate the topological order on every next/prev/save draft page transition because $form['#node'] is cached. This is rather unlike an entity controller where you explicitly want a fresh instance. I very much want to reuse the already-calculated object.
- I actually didn't know about graph.inc, but can reject it immediately because it uses a depth-first search. There are many valid topological sort orders of a DAG, but some are nicer for humans than others. A depth-first search tends to move conditionals deep on the page to the top of the order. While we don't currently care because we allow users to organize their conditionals as they see fit, in the future I am thinking that we might enforce a topological sort order on the order of the conditionals in the UI. This would let users see their dependencies automatically.
A second reason I don't care for graph.inc is because it uses input data structures where the edges and vertices are presented in separate arrays. This would mean processing the conditionals to create an intermediate representation before processing. But the time we've done that, and accounted for the extra edges implied by page breaks and field sets, I think it's easier to just calculate it ourselves.
The third reasons is the error generation. We would need a whole separate process just to look for errors, whereas they can easily be detected during the order generation.
- Re webform_get_conditional_sorter() -- that exactly what WebformConditionals::factory() does now. I can leave that feature in so that it might be useful to others. You get class auto-loading as a side benefit.
- I see your point about $node, but I still think the caching and ease-of-use trumps it. None of the data structures are recursive, so print_r won't go crazy.
- No problem on execute, although I think it does just what you'd think -- it executes the conditionals. Suggestion for the name? executeConditionals() seems redundant.
As you can probably see, all this infrastructure is there to support conditionals that change data. I'm not sure how or where this will go, but at a minimum setting values, possibly even with tokens (via ajax).
Comment #21
danchadwick commentedJust read the Kingdom of Nouns. The difference is that WebformConditionals represents a set of commands. And commands get, well, executed. It is unlike WebformSubmissionLoader::execute(), which the article would argue would better be WebformSubmission::load(). And I agree.
I do see incorporating more conditional logic into WebformConditionals, including the (now redundant) logic of determining which components should be included on a page.
Comment #22
danchadwick commentedI received a thoughtful off-line comment by torotil that the algorithm in topologicalSort is inadequately documented. I think I agree. Rather than relying on the user to visit the referenced page, I think some comments to explain the algorithm would help future maintainers, and I will add that.
Torotil also wondered whether the algorithm would be easier to understand if the nodes were objects. Indeed there is a open source PHP version which does just that. I considered it, but didn't use it because a) the representation of the nodes and vertices is not convenient for how the existing conditionals are stored b) the objects don't add must flexibility or power and c) creating a bunch of objects takes server time and memory.
He also expressed some concern about performance. I found that with 200 conditionals, each with one source, the algorithm ran on my modest development machine in 3ms, which I think is plenty acceptable. The algorithm is order # conditionals + # source conditions, so it is unlikely to turn horrible with any reasonable webform. Fieldsets and pages contribute implied edges, but even with these, its still linear unless your fieldsets contain sources that are frequently or/and deeply nested.
Comment #23
danchadwick commentedRe being a controller object, In the ideal world, the conditionals themselves would be the object that would know how to sort themselves, but that's waaaay too big a step to take. It is something that D8 should look at.
I googled and didn't find literature on why controller objects might be undesirable. Any references?
Comment #24
quicksketchI'd recommend watching Rich Hickey's Simple Made Easy video. There's nothing inherently wrong with controllers (or any other objects). But the reason for adding this code is because of the (substantially) increased complexity that we're trying to accommodate.
Because PHP is kind of dumb about print_r() (until PHP7), the WebformConditional's protected $node property will cause a recursive loop, because print_r() will print out not only public properties but also all protected and private ones. Since $node contains the WebformConditional class and the WebformConditional class contains its parent nodes, you'll get a recursive error when running print_r() (I haven't check this in the current code, but it's been my previous experience).
I still would feel much, much better if this were put into a separate location rather than in the $node directly. My suggestion about using a function like
webform_get_conditional_sorter()would require it's own static cache for each node that needs the sorting functionality. This pattern matches the other Controller-like classes in Drupal, including the controllers classes for saving and loading nodes and other entities. See entity_get_controller.Comment #25
danchadwick commentedIn PHP 5.3 I get a "error (NULL)" when I try to expand conditionals_sorter using the devel tab. print_r gives '*RECURSION*' for just the node back-link. Both I think are pretty benign side effects of the back-link to the node.
If this recursive $node link was really problematic, we could store the $nid and call node_load($this->nid) in every function. This would rely on the entity cache. Extra work and a performance hit for not much benefit, in my opinion.
Options:
This isn't an application of a complicated OOP pattern to a simple problem. The OOP code itself is trivial. The class just serves to keep the code dealing with a specific part of webform's functionality together in a way that is entirely compatible with D7's non-OOP architecture (e.g. all nodes are of type stdClass (ironic, given the acronym STD ;) )).
But, hey, I really just want to get this thing put to bed and move on. It's been a HUGE chunk of work and this is a very minor part of it. If you insist on 2 or 3, I will bow to your wishes. But I think 1 is best for the webform codebase and future webform maintainers. Assuming no other feedback, make the call and I'll commit the code. I want this commit to "cook" in the -dev branch for a while before release as a stable so that it gets extra testing.
Comment #26
quicksketchAh, I must be thinking of Drupal's
debug()function that has this problem. I think Devel'sdvm()might also suffer from it, while I knowdpm()handles recursion at least.I'd really prefer 2 or 3 here. Removing the factory and adding the
webform_get_conditionals_sorter()(or a name that is more fitting if needed) would be my preference (solution 2). I also like this solution because it moves the creation of the WebformConditional object outside ofnode_load(), meaning it's not going to be instantiated when the form itself isn't even being displayed, as may be the case in administrative listings, Views, certain display modes, edit forms, etc.Other last feedback:
I'd still like to see "execute" renamed to something more descriptive. Could we make topologicalSort() private or protected? It doesn't seem necessary to have it in the public namespace as it provides no public value.
And I haven't said it already: Good work on taking on this particularly challenging problem. :)
Comment #27
danchadwick commentedRevised patch per #26.
$node->webform['conditional_sorter']->method() -> webform_get_conditional_sorter($node)->method().
WebformConditionals::execute-> WebformCondtionals::executeOnSubmission()
Comment #28
quicksketchEven though $pageMap is an object, Drupal's coding standards still indicate this should use underscores instead of camelCase.
This wasn't addressed... should this be protected? I don't think it provides a public value.
Heh, that wasn't quite what I meant. "Execute" is the indescriptive word here. How about
filterInputValuesorcleanInputValues?I see you went with option #3 after all. I think that's a fine approach. Thanks for separating it out of the $node object.
Comment #29
danchadwick commentedAgree on $pageMap, although I think the standard is wrong. It obfuscates, rather than illuminates, to call a getter method and not be allowed to name the local variable that which was gotten.
Begrudgingly agree on protected for function topologicalSort(). The function is useful for re-sorting, effectively flushing the cache, since that now can't be done by re-instantiating $node->webform['conditional_sorter']. I am not a huge fan of protecting potentially-useful methods whose utility was not envisioned by the module creators. For example, the migrate module has/had tons of this, requiring patches. But in this case, webform maintainers control both the caller and object, so let's make it protected. We can always change it again. I loath the very concept of private. It's saying, "I know better than all who come after me that my way is perfect for all of time."
I think I'm not communicating why I think "execute" is the perfect name. Conditionals are a series of programming commands, not unlike Rules (the module), PHP itself, or machine code. They are executed on a submission in exactly the computer language/hardware sense. A program counter runs down the conditionals, performing them one at a time, reading and writing the submission. Right now, the only two ops are NOP (SHOW) and MAKE NULL (HIDE), but very soon I plan to add others like SET TO XXX. This is the sense in which I mean "execute".
Note that this is completely unlike MyComplicatedVerbifiedNounClass::execute() as described in the amusing tale that you referenced about Java.
Comment #31
danchadwick commentedRelative to #27, this patch:
A cache-clear is required to update the class registry.
Comment #32
danchadwick commentedfenstrat -- this is a big-un. It would be best to get this into D8 sooner rather than later due to the scope of changes.
Comment #33
danchadwick commentedIssue is no longer "META".
Comment #34
quicksketchThanks Dan, everything in #31 sounds good to me!
Okay that makes a lot more sense. If we were just trimming out invalid values, then "execute" doesn't describe the situation well. If we're going to add other actions besides hiding, then execute will fit in the future. +1
Comment #35
fenstratCommitted and pushed to 8.x-4.x. Thanks!
Great work here Dan. Sorry I had no time to respond to your architectural questions. The end result looks well done.
One question, for D7, as the class registry needs to be rebuilt is a hook_update_N() needed to trigger one on live sites?
Comment #37
danchadwick commentedMy understanding is that a clear cache is SOP after an update, no?
That said, I think we're in for a bigger hook update if we do the multiple targets / conditional issue. I'm not overly concerned.
Comment #38
fenstratSOP on some site perhaps. On large sites a cache clear can be a expensive process. I'm not sure of best practice here after changes to the class registry.
Comment #39
danchadwick commentedIf conditionals are defined out-of-order, then when they are executed on a submission, PHP warning will be generated and the conditionals will not execute correctly on the server.
Committed to 7.x-4.x and 8.x.