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 }