Recently at work I wrote a messy bit of JavaScript (jQuery really) to create movable AND/OR statements. I learned how to code less than a year ago and haven't done any intense programming (yet, just wait), so this is probably the most difficult, headache-inducing, neck-wringing problem I've worked on to date. The last time I encountered this much trouble was when I learned about recursion and tries. I figure I'll outline my thought process here in case it's useful to anyone else.
The assignment
The goal of these AND/OR statements was to allow users to perform searches with custom parameters. On the right side of the page would be two lists: one containing the available parameters (Location, Category, Subcategory) and the other containing the two operators (AND, OR). On the left two-thirds of the page would be where the user would drag the parameters and operators to build a full search statement.
Full, well-formed search statements would have this format: Location OR Category AND Subcategory
Making elements sortable is fairly simple. Two words: jQuery UI. No, that wasn't the hard part at all. The hard part was following the rules given to me, which involved much tweaking of the sortable interaction and, as always, fighting with the DOM.
The rules:
Parameters should never be next to parameters, and operators should never be next to operators, e.g., it shouldn't be possible to build statements like Location Category AND OR Subcategory.
Operators should never be at the beginning or ending of a statement, e.g., AND Category AND Subcategory OR. Pretty much anything that doesn't make sense in natural language shouldn't be possible.
When a parameter or operator (for brevity's sake I'll call them "items" from now on) is dragged from the sidebar into a statement, the sidebar should repopulate with the missing item.
Parameters can be dragged into statements, and operators can be dragged into statements. All other dragging between lists should not be allowed. In other words, a parameter can't be dragged into the list of operators, an operator can't be dragged into the list of parameters, and blocks in statements can't be dragged back into the lists (to be honest, I implemented this last rule to make it slightly easier for me). Also, items can't be dragged from one statement to another.
Since items in statements can't be dragged back into the list, there needs to be a way to clear a statement and start over.
Each statement begins with an empty dotted box, to indicate that a new item can be placed there. As items are placed in the statement, the dotted box should move over to the right until the statement reaches its item limit. The item limit is five, as there are only three parameters and two operators.
Items can be dragged within a statement, but again, parameters should never be next to parameters, and operators should never be next to operators.
Within a statement, if an item is dragged over a like block, it should switch with the like item. In other words, if "Location" is dragged over "Category," the two items should switch places.
When parameters are placed into a statement, they should become editable input boxes, so that the user can enter her preferred location, category, and subcategory.
When a user types into the "Location" input box, this should trigger an autocomplete place search. (I'd been playing with the Google Places API the week prior to this for my WeHo Eat Mo project, so this was fairly easy to implement.)
That's about it. I created some of the rules as I went along based on the issues I faced while interacting with the interface. Bugs just kept on cropping up. (In fact, while writing this, I discovered a few more. Code is never finished.)
If, else, and nothing else
The code I ended up writing is almost entirely made up of if–else blocks. Here's the nested conditional monster that controls the "sortability" of items (variables are defined at the beginning of the sort):
function sortRules(event, ui) { targetParent = ui.item.parent(); targetParent.children('.ui-state-dotted').remove(); // If the to-list is a statement and (1) the from-list is a item list or (2) the to-list and from-list are identical // Items can be rearranged within a single statement and dragged from a item list to a statement // Items cannot be dragged from one statement to another or from a statement to a item list if (targetParent.hasClass('fullStatement') && (originalParent.hasClass('piece'))) { // Add a dotted box (implying that another item can be added) if the total statement length is shorter than five items // If the total statement length is equal to five items, don't add another dotted box, but don't cancel the sort, either if (targetParent.children('li:not(.delete):not(.ui-state-dotted)').length < 5) { targetParent.append('<li class="ui-state-dotted"></li>'); // Cancel the sort if the statement length has six or more items (including the current item) } else if (targetParent.children('li:not(.delete):not(.ui-state-dotted)').length >= 6) { $(this).sortable('cancel'); } // Add a delete button if the statement does not already have it if (targetParent.children('.delete').length === 0) { $('<li class="delete">x</li>').insertBefore(targetParent.children('li:first-child')); } // Prevent like items from being placed next to each other // i.e., operators should never be next to operators, and parameters should never be next to parameters // Check if the item has the same class as the item before it or the item after it // or if the item is an operator and (1) the previous item is not a parameter (e.g., operator or undefined), // (2) the next item is an operator, and (3) the next item is undefined // This prevents operators from being placed next to each other, at the beginning or end of a statement, // but allows them to appear before a dotted box if ((ui.item.attr('class') === ui.item.prev().attr('class')) || (ui.item.attr('class') === ui.item.next().attr('class')) || (ui.item.hasClass('operator') && (!ui.item.prev().hasClass('parameter') || ui.item.next().hasClass('operator') || ui.item.next().attr('class') === undefined)) || ui.item.prev().hasClass('ui-state-dotted')) { $(this).sortable('cancel'); checkCanceled(targetParent); // Otherwise, if the sort arrangement is allowed, continue with the sort } else { if (ui.item.hasClass('parameter')) { ui.item.html('<form class="handler"><input type="text" class="search-input" name="search-input" value=""></form>'); ui.item.css('padding', '5px'); } if (originalText === 'Location') { ui.item.children('form').children('input').attr({ 'id': 'locationInput', 'placeholder': 'Enter your city' }); locationSearch(); } else { ui.item.children('form').children('input').attr('placeholder', originalText); } originalParent.append('<li class="' + ui.item.attr('class') + '">' + originalHTML + '</li>'); deleteParameter(targetParent); } } else if (targetParent.attr('id') === originalParent.attr('id')) { // Grab the new target index of the item (after the sort ends) newIndex = ui.item.index(); // Swap the current item with the item at its target index // if the target item is an even number away from the current item, // so that only like items are swapped with each other if (Math.abs(originalIndex - newIndex) % 2 === 0 && (ui.item.hasClass('operator') && !ui.item.prev().hasClass('operator'))) { ui.item.next().insertAfter(originalParent.children('li').eq(originalIndex)); } else { $(this).sortable('cancel'); checkCanceled(targetParent); } // Prevent items from being dragged from statements back into item lists } else { $(this).sortable('cancel'); checkCanceled(targetParent); } }
Yes, this function consists of one if–else block with...I don't even know how many nested if–else blocks inside of each of those conditions. It's terrifying. But it does the trick. Eventually I'd like to refactor this using a hash table, if that's possible. To see my progress on this project, check out my source code on GitHub.