1 /// Written in the D programming language. 2 3 module semitwistWeb.form; 4 5 import std.algorithm; 6 import std.array; 7 import std.conv; 8 import std..string; 9 import std.path : buildPath; 10 11 import arsd.dom; 12 import vibe.inet.webform; 13 14 import semitwist.util.all; 15 import semitwist.arsddom; 16 17 import mustacheLib = mustache; 18 alias mustacheLib.MustacheEngine!string Mustache; 19 20 void addFormContext(Mustache.Context c, FormSubmission[string] sessSubmissions, string[] formNames) 21 { 22 foreach(formName; formNames) 23 c.addFormContext(sessSubmissions, formName); 24 } 25 26 void addFormContext(Mustache.Context c, FormSubmission[string] sessSubmissions, string formName) 27 { 28 auto submissionPtr = formName in sessSubmissions; 29 if(!submissionPtr) 30 throw new Exception( 31 text("Form '", formName, "' can't be found in SessionData.submissions.") 32 ); 33 34 HtmlForm.get(formName).addFormDataContext(c, *submissionPtr); 35 } 36 37 /// formToKeep: For example, if formToKeep is "purchase", then 38 /// submissions["purchase"] will not be cleared, 39 /// but the rest will. 40 /// If formsToKeep is null or empty string, then all will be cleared. 41 void clearOtherForms(FormSubmission[string] submissions, string currUrl, string formToKeep) 42 { 43 // Validate formToKeep 44 if(formToKeep != "" && formToKeep !in submissions) 45 throw new Exception(text("Form name '", formToKeep, "' doesn't exist in submissions.")); 46 47 // Clear all except formToKeep 48 foreach(name, val; submissions) 49 if(name != formToKeep || submissions[name].url != currUrl) 50 submissions[name].clear(); 51 } 52 53 /// formsToKeep: For example, if formsToKeep is ["purchase", "foobar"], 54 /// then submissions["purchase"] and submissions["foobar"] 55 /// will not be cleared, but the rest will. 56 /// If formsToKeep is empty, then all will be cleared. 57 void clearOtherForms(FormSubmission[string] submissions, string currUrl, string[] formsToKeep) 58 { 59 // Validate formsToKeep 60 foreach(name; formsToKeep) 61 if(name !in submissions) 62 throw new Exception(text("Form name '", name, "' doesn't exist in submissions.")); 63 64 // Clear all except formsToKeep 65 foreach(name, val; submissions) 66 if(!formsToKeep.contains(name) || submissions[name].url != currUrl) 67 submissions[name].clear(); 68 } 69 70 enum FormElementType 71 { 72 Text, 73 TextArea, 74 Password, 75 Button, 76 ErrorLabel, 77 } 78 79 enum FormElementOptional 80 { 81 No, Yes 82 } 83 84 struct FormElement 85 { 86 FormElementType type; 87 88 private string _name; 89 @property string name() { return _name; } 90 91 string label; 92 string defaultValue; 93 string confirmationOf; 94 95 private FormElementOptional _isOptional = FormElementOptional.No; 96 @property FormElementOptional isOptional() 97 { 98 return _isOptional; 99 } 100 @property void isOptional(FormElementOptional value) 101 { 102 if(type == FormElementType.Button || type == FormElementType.ErrorLabel) 103 _isOptional = FormElementOptional.Yes; 104 else 105 _isOptional = value; 106 } 107 108 private string _mustacheTagValue; 109 @property string mustacheTagValue() 110 { 111 if(!_mustacheTagValue) 112 _mustacheTagValue = name ~ "-value"; 113 114 return _mustacheTagValue; 115 } 116 117 private string _mustacheTagExtraClass; 118 @property string mustacheTagExtraClass() 119 { 120 if(!_mustacheTagExtraClass) 121 _mustacheTagExtraClass = name ~ "-extra-class"; 122 123 return _mustacheTagExtraClass; 124 } 125 126 this( 127 FormElementType type, string name, string label, 128 string defaultValue = "", string confirmationOf = "", 129 FormElementOptional isOptional = FormElementOptional.No 130 ) 131 { 132 this.type = type; 133 this._name = name; 134 this.label = label; 135 this.defaultValue = defaultValue; 136 this.confirmationOf = confirmationOf; 137 this.isOptional = isOptional; 138 } 139 140 //TODO: Support radios and checkboxes 141 static FormElement fromDom(Element inputElem, Form form, string filename="") 142 { 143 auto formId = requireAttribute(form, "id")[HtmlForm.formIdPrefix.length..$]; 144 auto isInputTag = inputElem.tagName.toLower() == "input"; 145 auto isTextAreaTag = inputElem.tagName.toLower() == "textarea"; 146 auto isErrorLabel = inputElem.tagName.toLower() == "validate-error"; 147 148 if(isErrorLabel) 149 { 150 foreach(ref labelTextElem; inputElem.getElementsByTagName("label-text")) 151 labelTextElem.outerHTML = "{{{form-"~formId~"-errorMsg}}}"; 152 153 inputElem.outerHTML = 154 "{{#form-"~formId~"-hasErrorMsg}}" ~ 155 inputElem.innerHTML ~ 156 "{{/form-"~formId~"-hasErrorMsg}}"; 157 158 return FormElement(FormElementType.ErrorLabel, "input-errorlabel", ""); 159 } 160 161 if(!isInputTag && !isTextAreaTag) 162 { 163 throw new Exception( 164 "Unknown type of input element (tagName: '"~inputElem.tagName~ 165 "') on form '"~formId~"' in file '"~ 166 (filename==""?"{unknown}":filename)~"'" 167 ); 168 } 169 170 // Id 171 auto inputId = requireAttribute(inputElem, "id"); 172 inputElem.setAttribute("name", inputId); 173 174 // Type of input 175 FormElementType inputType = FormElementType.TextArea; 176 if(!isTextAreaTag) 177 { 178 auto typeName = requireAttribute(inputElem, "type"); 179 switch(typeName) 180 { 181 case "text": inputType = FormElementType.Text; break; 182 case "password": inputType = FormElementType.Password; break; 183 case "submit": inputType = FormElementType.Button; break; 184 default: 185 throw new Exception("Unknown value on <input>'s type attribute: '"~typeName~"'"); 186 } 187 } 188 189 // Label 190 string labelText; 191 if(inputType == FormElementType.Button) 192 labelText = inputElem.getAttribute("label-text"); 193 else 194 labelText = requireAttribute(inputElem, "label-text"); 195 196 auto labelElem = form.getLabel(inputId); 197 if(!labelElem && inputType != FormElementType.Button) 198 throw new Exception("Missing <label> with 'for' attribute of '"~inputId~"' (in template '"~filename~"')"); 199 200 if(labelElem) 201 foreach(ref labelTextElem; labelElem.getElementsByTagName("label-text")) 202 labelTextElem.outerHTML = labelText; 203 204 inputElem.removeAttribute("label-text"); 205 206 // Confirmation of... 207 string confirmationOf = null; 208 if(inputElem.hasAttribute("confirms")) 209 { 210 confirmationOf = inputElem.getAttribute("confirms"); 211 inputElem.removeAttribute("confirms"); 212 } 213 214 // Is optional? 215 auto isOptional = FormElementOptional.No; 216 if(inputElem.hasAttribute("optional")) 217 { 218 isOptional = FormElementOptional.Yes; 219 inputElem.removeAttribute("optional"); 220 } 221 222 // Default value 223 auto defaultValue = form.getValue(inputId); 224 if(inputType != FormElementType.Button) 225 { 226 //TODO: This will need to be different for radio/checkbox: 227 form.setValue(inputId, "{{"~inputId~"-value}}"); 228 } 229 230 // Validate error class 231 if(labelElem) 232 { 233 inputElem.addClass("{{"~inputId~"-extra-class}}"); 234 labelElem.addClass("{{"~inputId~"-extra-class}}"); 235 } 236 237 // Create element 238 return FormElement( 239 inputType, inputId, labelText, 240 defaultValue, confirmationOf, isOptional 241 ); 242 } 243 244 bool isCompatible(FormElement other) 245 { 246 // Ignore 'label' and 'defaultValue' 247 return 248 this.type == other.type && 249 this.name == other.name && 250 this.confirmationOf == other.confirmationOf && 251 this.isOptional == other.isOptional; 252 } 253 } 254 255 string makeErrorMessage(FormSubmission submission) 256 { 257 static string validateErrorFieldNameSpan(string name) 258 { 259 return `<span class="validate-error-field-name">`~name~"</span>"; 260 } 261 262 // Collect all errors found 263 FieldError errors = FieldError.None; 264 foreach(fieldName, err; submission.invalidFields) 265 errors |= err; 266 267 // Add a "missing field(s)" message if needed 268 string[] errMsgs; 269 if(errors & FieldError.Missing) 270 { 271 if(submission.missingFields.length == 1) 272 errMsgs ~= 273 "The required field "~ 274 validateErrorFieldNameSpan(submission.missingFields[0].label)~ 275 " is missing."; 276 else 277 { 278 string invalidLabels; 279 foreach(invalidElem; submission.missingFields) 280 { 281 if(invalidLabels != "") 282 invalidLabels ~= ", "; 283 invalidLabels ~= validateErrorFieldNameSpan(invalidElem.label); 284 } 285 errMsgs ~= "These required fields are missing: "~invalidLabels; 286 } 287 } 288 289 // Add "confirmation failed" messages if needed 290 if(errors & FieldError.ConfirmationFailed) 291 foreach(invalidElem; submission.confirmationFailedFields) 292 errMsgs ~= 293 "The "~ 294 validateErrorFieldNameSpan(submission.form[invalidElem.confirmationOf].label)~ 295 " and "~ 296 validateErrorFieldNameSpan(invalidElem.label)~ 297 " fields don't match."; 298 299 // Combine all error messages 300 string comboErrMsg; 301 if(errMsgs.length == 1) 302 comboErrMsg = "<span>"~errMsgs[0]~"</span>"; 303 else 304 { 305 comboErrMsg = "<span>Please fix the following:</span><ul>"; 306 foreach(msg; errMsgs) 307 comboErrMsg ~= "<li>"~msg~"</li>"; 308 comboErrMsg ~= "</ul>"; 309 } 310 311 return comboErrMsg; 312 } 313 314 enum FieldError 315 { 316 None = 0, 317 Missing = 0b0000_0000_0001, 318 ConfirmationFailed = 0b0000_0000_0010, 319 Custom1 = 0b0001_0000_0000, // App-specific error 320 Custom2 = 0b0010_0000_0000, // App-specific error 321 Custom3 = 0b0100_0000_0000, // App-specific error 322 Custom4 = 0b1000_0000_0000, // App-specific error 323 } 324 325 final class FormSubmission 326 { 327 HtmlForm form; 328 329 this() 330 { 331 clear(); 332 } 333 334 private bool _isValid; 335 @property bool isValid() { return _isValid; } 336 @property void isValid(bool value) 337 { 338 _isValid = value; 339 340 if(_isValid) 341 _errorMsg = ""; 342 else if(_errorMsg == "") 343 _errorMsg = "A problem occurred, please try again later."; 344 } 345 346 string url; 347 FormFields fields; // Indexed by form element name 348 FieldError[string] invalidFields; // Indexed by form element name 349 FormElement[] missingFields; 350 FormElement[] confirmationFailedFields; 351 352 private string _errorMsg; 353 @property string errorMsg() { return _errorMsg; } 354 @property void errorMsg(string value) 355 { 356 _errorMsg = value; 357 _isValid = value == ""; 358 } 359 360 void clear() 361 { 362 isValid = true; 363 _errorMsg = null; 364 365 fields = FormFields(); 366 url = null; 367 missingFields = null; 368 invalidFields = null; 369 confirmationFailedFields = null; 370 } 371 372 void setFieldError(string name, FieldError err) 373 { 374 setFieldError(form[name], err); 375 } 376 377 void setFieldError(FormElement formElem, FieldError err) 378 { 379 void setInvalidField(string _name, FieldError _err) 380 { 381 isValid = false; 382 383 if(_name in invalidFields) 384 invalidFields[_name] |= _err; 385 else 386 invalidFields[_name] = _err; 387 } 388 389 auto name = formElem.name; 390 391 if(err & FieldError.Missing && !hasError(name, FieldError.Missing)) 392 missingFields ~= formElem; 393 394 if(err & FieldError.ConfirmationFailed && !hasError(name, FieldError.ConfirmationFailed)) 395 { 396 confirmationFailedFields ~= formElem; 397 398 auto currElem = formElem; 399 while(currElem.confirmationOf != "") 400 { 401 currElem = form[currElem.confirmationOf]; 402 setInvalidField(currElem.name, FieldError.ConfirmationFailed); 403 } 404 } 405 406 setInvalidField(name, err); 407 } 408 409 bool hasError(string name, FieldError err) 410 { 411 if(auto errPtr = name in invalidFields) 412 if(*errPtr & err) 413 return true; 414 415 return false; 416 } 417 } 418 419 /// Handles null safely and correctly 420 @property bool isClear(FormSubmission submission) 421 { 422 if(!submission) 423 return true; 424 425 return submission.fields.length == 0 && submission.isValid; 426 } 427 428 // Just to help avoid excess reallocations. 429 private FormSubmission _blankFormSubmission; 430 private @property FormSubmission blankFormSubmission() 431 { 432 if(!_blankFormSubmission) 433 _blankFormSubmission = new FormSubmission(); 434 435 return _blankFormSubmission; 436 } 437 438 struct HtmlForm 439 { 440 static enum formIdPrefix = "form-"; 441 442 private string _name; 443 @property string name() { return _name; } 444 445 string origFilename; 446 447 private string _mustacheTagHasErrorMsg; 448 @property string mustacheTagHasErrorMsg() 449 { 450 if(!_mustacheTagHasErrorMsg) 451 _mustacheTagHasErrorMsg = "form-"~name~"-hasErrorMsg"; 452 453 return _mustacheTagHasErrorMsg; 454 } 455 456 private string _mustacheTagErrorMsg; 457 @property string mustacheTagErrorMsg() 458 { 459 if(!_mustacheTagErrorMsg) 460 _mustacheTagErrorMsg = "form-"~name~"-errorMsg"; 461 462 return _mustacheTagErrorMsg; 463 } 464 465 private FormElement[] elements; 466 private FormElement[string] elementLookup; 467 468 this(string name, string origFilename, FormElement[] elements) 469 { 470 this._name = name.strip(); 471 this.origFilename = origFilename; 472 this.elements = elements; 473 474 validateElements(); 475 476 FormElement[string] lookup; 477 foreach(elem; elements) 478 lookup[elem.name] = elem; 479 elementLookup = lookup.dup; 480 } 481 482 private static HtmlForm[string] registeredForms; 483 static HtmlForm get(string formName) 484 { 485 return registeredForms[formName]; 486 } 487 488 private static string[] registeredFormNames; 489 private static bool registeredFormNamesInited = false; 490 static string[] getNames() 491 { 492 if(!registeredFormNamesInited) 493 { 494 registeredFormNames = registeredForms.keys; 495 registeredFormNamesInited = true; 496 } 497 498 return registeredFormNames; 499 } 500 501 static FormSubmission[string] newSessionData() 502 { 503 FormSubmission[string] submissions; 504 505 foreach(name; HtmlForm.getNames()) 506 submissions[name] = new FormSubmission(); 507 508 return submissions; 509 } 510 511 /// Returns: Same HtmlForm provided, for convenience. 512 static HtmlForm register(HtmlForm form) 513 { 514 if(form.name in registeredForms) 515 throw new Exception(text("A form named '", form.name, "' is already registered.")); 516 517 registeredForms[form.name] = form; 518 registeredFormNames = null; 519 registeredFormNamesInited = false; 520 return form; 521 } 522 523 /// Returns: Processed template html 524 static string registerFromTemplate(string filename, string rawHtml, bool overwriteExisting=false) 525 { 526 //TODO: Somehow fix this "Temporarily escape mustache partials" so it works with alternate delimeters 527 528 // Temporarily escape mustache partials start so DOM doesn't destroy it 529 rawHtml = rawHtml.replace("{{>", "{{MUSTACHE-GT"); 530 // The <div> is needed so the DOM doesn't strip out leading/trailing mustache tags. 531 rawHtml = "<div>"~rawHtml~"</div>"; 532 533 auto doc = new Document(rawHtml, true, true); 534 foreach(form; doc.forms) 535 if(form.hasClass("managed-form")) 536 registerForm(form, filename, overwriteExisting); 537 538 // Unescape mustache partials start so mustache can read it 539 auto bakedHtml = doc.toString().replace("{{MUSTACHE-GT", "{{>"); 540 return bakedHtml; 541 } 542 543 static void registerForm(Form form, string filename, bool overwriteExisting=false) 544 { 545 // Generate the new HtmlForm's elements from HTML DOM (and adjust the DOM as needed) 546 string formId; 547 FormElement[] formElems; 548 try 549 { 550 // Form ID 551 formId = requireAttribute(form, "id"); 552 if(!formId.startsWith(formIdPrefix) || formId.length <= formIdPrefix.length) 553 throw new Exception("Form id '"~formId~"' doesn't start with required prefix '"~formIdPrefix~"'"); 554 formId = formId[formIdPrefix.length..$]; 555 form.setAttribute("name", formId); 556 557 // Form Elements 558 foreach(elem; form.getElementsByTagName("input")) 559 formElems ~= FormElement.fromDom(elem, form, filename); 560 561 foreach(elem; form.getElementsByTagName("textarea")) 562 formElems ~= FormElement.fromDom(elem, form, filename); 563 564 foreach(elem; form.getElementsByTagName("validate-error")) 565 formElems ~= FormElement.fromDom(elem, form, filename); 566 } 567 catch(MissingHtmlAttributeException e) 568 { 569 e.setTo(e.elem, e.attrName, filename); 570 throw e; 571 } 572 573 // Register new HtmlForm, if necessary 574 auto newHtmlForm = HtmlForm(formId, filename, formElems); 575 if(isRegistered(formId)) 576 { 577 auto oldHtmlForm = HtmlForm.get(formId); 578 if(overwriteExisting) 579 { 580 oldHtmlForm.unregister(); 581 HtmlForm.register(newHtmlForm); 582 } 583 else if(!newHtmlForm.isCompatible(oldHtmlForm)) 584 throw new Exception("Redefinition of form '"~formId~"' in '"~filename~"' is incompatible with existing definition in '"~oldHtmlForm.origFilename~"'"); 585 } 586 else 587 HtmlForm.register(newHtmlForm); 588 } 589 590 static bool isRegistered(string formName) 591 { 592 return !!(formName in registeredForms); 593 } 594 595 bool isRegistered() 596 { 597 return 598 name in registeredForms && this == registeredForms[name]; 599 } 600 601 void unregister() 602 { 603 if(!isRegistered()) 604 { 605 if(name in registeredForms) 606 throw new Exception(text("Cannot unregister form '", name, "' because it's unequal to the registered form of the same name.")); 607 else 608 throw new Exception(text("Cannot unregister form '", name, "' because it's not registered.")); 609 } 610 611 registeredForms.remove(name); 612 registeredFormNames = null; 613 registeredFormNamesInited = false; 614 } 615 616 FormElement opIndex(string name) 617 { 618 return elementLookup[name]; 619 } 620 621 FormElement* opBinaryRight(string op)(string name) if(op == "in") 622 { 623 return name in elementLookup; 624 } 625 626 bool isCompatible(HtmlForm other) 627 { 628 if(this.name != other.name) 629 return false; 630 631 if(this.elements.length != other.elements.length) 632 return false; 633 634 //TODO: Don't require elements to be in the same order 635 foreach(i; 0..this.elements.length) 636 if(!this.elements[i].isCompatible( other.elements[i] )) 637 return false; 638 639 return true; 640 } 641 642 void addFormDataContext(Mustache.Context c) 643 { 644 addFormDataContextImpl(c, false, blankFormSubmission); 645 } 646 647 void addFormDataContext(Mustache.Context c, FormSubmission submission) 648 { 649 addFormDataContextImpl(c, true, submission); 650 } 651 652 private void addFormDataContextImpl(Mustache.Context c, bool useSubmission, FormSubmission submission) 653 { 654 foreach(elem; elements) 655 { 656 final switch(elem.type) 657 { 658 case FormElementType.Text: 659 case FormElementType.TextArea: 660 case FormElementType.Password: 661 string value = ""; 662 if(useSubmission && elem.name in submission.fields && submission.fields[elem.name] != "") 663 value = submission.fields[elem.name]; 664 665 auto errorCode = submission.invalidFields.get(elem.name, FieldError.None); 666 auto hasError = errorCode != FieldError.None; 667 668 c[elem.mustacheTagValue] = value; 669 c[elem.mustacheTagExtraClass] = hasError? "validate-error" : ""; 670 break; 671 672 case FormElementType.Button: 673 // Do nothing 674 break; 675 676 case FormElementType.ErrorLabel: 677 if(useSubmission && submission.errorMsg != "") 678 { 679 c.useSection(submission.form.mustacheTagHasErrorMsg); 680 c[submission.form.mustacheTagErrorMsg] = submission.errorMsg; 681 } 682 break; 683 } 684 } 685 } 686 687 ///ditto 688 FormSubmission process( 689 FormSubmission submission, string url, FormFields data 690 ) 691 { 692 return partialProcess(submission, url, data, elements); 693 } 694 695 ///ditto 696 FormSubmission partialProcess( 697 FormSubmission submission, string url, FormFields data, FormElement[] elementsToProcess 698 ) 699 { 700 submission.clear(); 701 submission.form = this; 702 submission.url = url; 703 704 // Get responses and check for required fields that are missing 705 foreach(formElem; elementsToProcess) 706 { 707 bool valueExists = false; 708 if(formElem.name in data) 709 { 710 auto value = data[formElem.name].strip(); 711 submission.fields[formElem.name] = value; 712 if(value != "") 713 valueExists = true; 714 } 715 716 if(!valueExists && formElem.isOptional == FormElementOptional.No) 717 submission.setFieldError(formElem, FieldError.Missing); 718 } 719 720 // Check confirmation fields 721 foreach(formElem; elementsToProcess) 722 if(formElem.confirmationOf != "") 723 { 724 if(submission.fields[formElem.name] != submission.fields[formElem.confirmationOf]) 725 submission.setFieldError(formElem, FieldError.ConfirmationFailed); 726 } 727 728 // Did anything fail? 729 if(!submission.isValid) 730 submission.errorMsg = makeErrorMessage(submission); 731 732 return submission; 733 } 734 735 private void validateElements() 736 { 737 void error(string msg) 738 { 739 throw new Exception(origFilename~" [form: '"~name~"']: "~msg); 740 } 741 742 bool buttonFound = false; 743 bool errorLabelFound = false; 744 foreach(elemIndex, elem; elements) 745 { 746 bool isConfirmationOfOk = elem.confirmationOf == ""; 747 748 foreach(elem2Index, elem2; elements) 749 if(elemIndex != elem2Index) 750 { 751 if(elem.name == elem2.name.strip()) 752 error("Duplicate form element name: "~elem.name); 753 754 if(elem.confirmationOf == elem2.name) 755 isConfirmationOfOk = true; 756 } 757 758 if(elem.name == "") 759 error("Form element name is empty"); 760 761 if(elem.name.endsWith("-label")) 762 error(`Form element name cannot end with "-label"`); 763 764 if(elem.name.endsWith("-value")) 765 error(`Form element name cannot end with "-value"`); 766 767 if(elem.name.endsWith("-extra-class")) 768 error(`Form element name cannot end with "-extra-class"`); 769 770 if(elem.confirmationOf == elem.name) 771 error("Form element cannot be confirmationOf itself: "~elem.name); 772 773 if(!isConfirmationOfOk) 774 error("Form element '"~elem.name~"' is confirmationOf a non-existent element: "~elem.confirmationOf); 775 776 if(elem.type == FormElementType.Button) 777 buttonFound = true; 778 779 if(elem.type == FormElementType.ErrorLabel) 780 errorLabelFound = true; 781 } 782 783 if(!buttonFound) 784 error("No Button element on form"); 785 786 if(!errorLabelFound) 787 error("No ErrorLabel element on form"); 788 } 789 }