blob: b36e7d0dcdac6046a87d032977bbb302d294ecab [file] [log] [blame]
// Copyright 2007, 2008 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
var Tapestry = {
FORM_VALIDATE_EVENT : "form:validate",
FORM_PREPARE_FOR_SUBMIT_EVENT : "form:prepareforsubmit",
DEBUG_ENABLED : false,
/** Time, in seconds, that console messages are visible. */
CONSOLE_DURATION : 10,
FormEvent : Class.create(),
FormEventManager : Class.create(),
FieldEventManager : Class.create(),
Zone : Class.create(),
FormFragment : Class.create(),
FormInjector : Class.create(),
ErrorPopup : Class.create(),
// An array of Tapestry.ErrorPopup instances that have been created for fields within the page.
errorPopups : [],
// Adds a callback function that will be invoked when the DOM is loaded (which
// occurs *before* window.onload, which has to wait for images and such to load
// first. This simply observes the dom:loaded event on the document object (support for
// which is provided by Prototype).
onDOMLoaded : function(callback)
{
document.observe("dom:loaded", callback);
},
/** Find all elements marked with the "t-invisible" CSS class and hide()s them, so that
* Prototype's visible() method operates correctly. This is invoked when the
* DOM is first loaded, and AGAIN whenever dynamic content is loaded via the Zone
* mechanism. In addition, adds a focus listener for each form element.
*/
onDomLoadedCallback : function()
{
$$(".t-invisible").each(function(element)
{
element.hide();
element.removeClassName("t-invisible");
});
// Adds a focus observer that fades all error popups except for the
// field in question.
$$("INPUT", "SELECT", "TEXTAREA").each(function(element)
{
// Due to Ajax, we may execute the callback multiple times,
// and we don't want to add multiple listeners to the same
// element.
if (! element.isObservingFocusChange)
{
element.observe("focus", function()
{
Tapestry.focusedElement = element;
$(Tapestry.errorPopups).each(function(popup)
{
popup.handleFocusChange(element);
});
});
element.isObservingFocusChange = true;
}
});
},
// Generalized initialize function for Tapestry, used to help minimize the amount of JavaScript
// for the page by removing redundancies such as repeated Object and method names. The spec
// is a hash whose keys are the names of methods of the Tapestry object.
// The value is an array of arrays. The outer arrays represent invocations
// of the method. The inner array are the parameters for each invocation.
// As an optimization, the inner value may not be an array but instead
// a single value.
init : function(spec)
{
$H(spec).each(function(pair)
{
var functionName = pair.key;
// Tapestry.logWarning("Initialize: #{name} ...", { name:functionName });
var initf = Tapestry.Initializer[functionName];
if (initf == undefined)
{
Tapestry.error("Function Tapestry.Initializer.#{name}() does not exist.", { name:functionName });
return;
}
pair.value.each(function(parameterList)
{
if (! Object.isArray(parameterList))
{
parameterList = [parameterList];
}
initf.apply(this, parameterList);
});
});
},
error : function (message, substitutions)
{
Tapestry.updateConsole("t-err", message, substitutions);
},
warn : function (message, substitutions)
{
Tapestry.updateConsole("t-warn", message, substitutions);
},
debug : function (message, substitutions)
{
if (Tapestry.DEBUG_ENABLED)
Tapestry.updateConsole("t-debug", message, substitutions);
},
updateConsole : function (className, message, substitutions)
{
if (substitutions != undefined)
message = message.interpolate(substitutions);
if (Tapestry.console == undefined)
{
var body = $$("BODY").first();
Tapestry.console = new Element("div", { 'class': "t-console" });
body.insert({ bottom: Tapestry.console });
}
var div = new Element("div", { 'class': className }).update(message);
Tapestry.console.insert({ bottom: div });
var effect = new Effect.BlindUp(div, { delay: Tapestry.CONSOLE_DURATION,
afterFinish: function()
{
div.remove();
}});
div.observe("click", function()
{
effect.cancel();
div.remove();
});
},
getFormEventManager : function(form)
{
form = $(form);
var manager = form.eventManager;
if (manager == undefined)
manager = new Tapestry.FormEventManager(form);
return manager;
},
/**
* Passed the JSON content of a Tapestry partial markup response, extracts
* the script key (if present) and evals it, then uses the DOM loaded callback
* to hide invisible fields and add notifications for any form elements.
*/
processScriptInReply : function(reply)
{
if (reply.script != undefined)
eval(reply.script);
Tapestry.onDomLoadedCallback();
},
// Adds a validator for a field. A FieldEventManager is added, if necessary.
// The validator will be called only for non-blank values, unless acceptBlank is
// true (in most cases, acceptBlank is flase). The validator is a function
// that accepts the current field value as its first parameter, and a
// Tapestry.FormEvent as its second. It can invoke recordError() on the event
// if the input is not valid.
addValidator : function(field, acceptBlank, validator)
{
this.getFieldEventManager(field).addValidator(acceptBlank, validator);
},
getFieldEventManager : function(field)
{
field = $(field);
var manager = field.fieldEventManager;
if (manager == undefined) manager = new Tapestry.FieldEventManager(field);
return manager;
}
};
/** Container of functions that may be invoked by the Tapestry.init() function. */
Tapestry.Initializer = {
/**
* Convert a form or link into a trigger of an Ajax update that
* updates the indicated Zone.
*/
linkZone : function(element, zoneDiv)
{
element = $(element);
var zone = $(zoneDiv).zone;
var successHandler = function(transport)
{
var reply = transport.responseJSON;
zone.show(reply.content);
Tapestry.processScriptInReply(reply);
};
if (element.tagName == "FORM")
{
// The existing handler, if present, will be responsible for form validations, which must
// come before submitting the form via XHR.
var existingHandler = element.onsubmit;
var handler = function(event)
{
if (existingHandler != undefined)
{
var existingResult = existingHandler.call(element, event);
if (! existingResult) return false;
}
element.request({ onSuccess : successHandler });
Event.stop(event); // Should be domevent.stop(), but that fails under IE
return false;
};
element.onsubmit = handler;
return;
}
// Otherwise, assume it's just an ordinary link.
var handler = function(event)
{
new Ajax.Request(element.href, { onSuccess : successHandler });
return false;
};
element.onclick = handler;
},
validate : function (field, specs)
{
field = $(field);
Tapestry.getFormEventManager(field.form);
specs.each(function(spec)
{
// spec is a 2 or 3 element array.
// validator function name, message, optional constraint
var name = spec[0];
var message = spec[1];
var constraint = spec[2];
var vfunc = Tapestry.Validator[name];
if (vfunc == undefined)
{
Tapestry.error("Function Tapestry.Validator.#{name}() does not exist for field '#{fieldName}'.", {name:name, fieldName:pair.key});
return;
}
vfunc.call(this, field, message, constraint);
});
},
zone : function(spec)
{
new Tapestry.Zone(spec);
},
formFragment : function(spec)
{
new Tapestry.FormFragment(spec)
},
formInjector : function(spec)
{
new Tapestry.FormInjector(spec);
},
// Links a FormFragment to a trigger (a radio or a checkbox), such that changing the trigger will hide
// or show the FormFragment. Care should be taken to render the page with the
// checkbox and the FormFragment('s visibility) in agreement.
linkTriggerToFormFragment : function(trigger, element)
{
trigger = $(trigger);
if (trigger.type == "radio")
{
$(trigger.form).observe("click", function()
{
$(element).formFragment.setVisible(trigger.checked);
});
return;
}
// Otherwise, we assume it is a checkbox. The difference is
// that we can observe just the single checkbox element,
// rather than handling clicks anywhere in the form (as with
// the radio).
trigger.observe("click", function()
{
$(element).formFragment.setVisible(trigger.checked);
});
}
};
// New methods added to Element.
Tapestry.ElementAdditions = {
// This is added to all Elements, but really only applys to form control elements. This method is invoked
// when a validation error is associated with a field. This gives the field a chance to decorate itself, its label
// and its icon.
decorateForValidationError : function(element, message)
{
Tapestry.getFieldEventManager(element).addDecorations(message);
},
removeDecorations : function(element)
{
Tapestry.getFieldEventManager(element).removeDecorations();
},
// Checks to see if an element is truly visible, meaning the receiver and all
// its anscestors (up to the containing form), are visible.
isDeepVisible : function(element)
{
element = $(element);
if (! element.visible()) return false;
// Stop at a form, which is sufficient for validation purposes.
if (element.tagName == "FORM") return true;
return $(element.parentNode).isDeepVisible();
}
};
Element.addMethods(Tapestry.ElementAdditions);
// Collection of field based functions related to validation. Each
// function takes a field, a message and an optional constraint value.
Tapestry.Validator = {
required : function(field, message)
{
Tapestry.addValidator(field, true, function(value, event)
{
if (value.strip() == '')
event.recordError(message);
});
},
minlength : function(field, message, length)
{
Tapestry.addValidator(field, false, function(value, event)
{
if (value.length < length)
event.recordError(message);
});
},
maxlength : function(field, message, maxlength)
{
Tapestry.addValidator(field, false, function(value, event)
{
if (value.length > maxlength)
event.recordError(message);
});
},
min : function(field, message, minValue)
{
Tapestry.addValidator(field, false, function(value, event)
{
if (value < minValue)
event.recordError(message);
});
},
max : function(field, message, maxValue)
{
Tapestry.addValidator(field, false, function(value, event)
{
if (value > maxValue)
event.recordError(message);
});
},
regexp : function(field, message, pattern)
{
var regexp = new RegExp(pattern);
Tapestry.addValidator(field, false, function(value, event)
{
if (! regexp.test(value))
event.recordError(message);
});
}
};
// A Tapestry.FormEvent is used when the form sends presubmit and submit events to
// a FieldEventManager. It allows the associated handlers to indirectly invoke
// the Form's invalidField() method, and it tracks a result flag (true for success ==
// no field errors, false if any field errors).
Tapestry.FormEvent.prototype = {
initialize : function(form)
{
this.form = $(form);
this.result = true;
this.firstError = true;
},
// Invoked by a validator function (which is passed the event) to record an error
// for the associated field. The event knows the field and form and invoke's
// the (added) form method invalidField(). Sets the event's result field to false
// (i.e., don't allow the form to submit), and sets the event's error field to
// true.
recordError : function(message)
{
if (this.firstError)
{
this.field.activate();
this.firstError = false;
}
this.field.decorateForValidationError(message);
this.result = false;
this.error = true;
}
};
Tapestry.ErrorPopup.prototype = {
BUBBLE_VERT_OFFSET : -34,
BUBBLE_HORIZONTAL_OFFSET : -5,
BUBBLE_WIDTH: "auto",
BUBBLE_HEIGHT: "39px",
initialize : function(field)
{
this.field = $(field);
this.innerSpan = new Element("span");
this.outerDiv = $(new Element("div", { 'class' : 't-error-popup' })).update(this.innerSpan).hide();
var body = $$('BODY').first();
body.insert({ bottom: this.outerDiv });
this.outerDiv.absolutize();
this.outerDiv.observe("click", function(event)
{
this.stopAnimation();
this.outerDiv.hide();
this.field.focus();
Event.stop(event); // Should be domevent.stop(), but that fails under IE
}.bindAsEventListener(this));
Tapestry.errorPopups.push(this);
this.state = "hidden";
this.queue = { position: 'end', scope: this.field.id };
Event.observe(window, "resize", this.repositionBubble.bind(this));
},
showMessage : function(message)
{
this.stopAnimation();
this.innerSpan.update(message);
this.hasMessage = true;
this.fadeIn();
},
repositionBubble : function()
{
var fieldPos = this.field.positionedOffset();
this.outerDiv.setStyle({
top: (fieldPos[1] + this.BUBBLE_VERT_OFFSET) + "px",
left: (fieldPos[0] + this.BUBBLE_HORIZONTAL_OFFSET) + "px",
width: this.BUBBLE_WIDTH,
height: this.BUBBLE_HEIGHT });
},
fadeIn : function()
{
this.repositionBubble();
if (this.state == "hidden")
{
this.state = "visible";
this.animation = new Effect.Appear(this.outerDiv, { afterFinish : this.afterFadeIn.bind(this), queue: this.queue });
}
},
stopAnimation : function()
{
if (this.animation) this.animation.cancel();
this.animation = null;
},
fadeOut : function ()
{
this.stopAnimation();
if (this.state == "visible")
{
this.state = "hidden";
this.animation = new Effect.Fade(this.outerDiv, { queue : this.queue });
}
},
hide : function()
{
this.hasMessage = false;
this.stopAnimation();
this.outerDiv.hide();
this.state = "hidden";
},
afterFadeIn : function()
{
this.animation = null;
if (this.field != Tapestry.focusedElement) this.fadeOut();
},
handleFocusChange : function(element)
{
if (element == this.field)
{
if (this.hasMessage) this.fadeIn();
return;
}
if (this.animation == null)
{
this.fadeOut();
return;
}
// Must be fading in, let it finish, then fade it back out.
this.animation = new Effect.Fade(this.outerDiv, { queue : this.queue });
}
};
Tapestry.FormEventManager.prototype = {
initialize : function(form)
{
this.form = $(form);
this.form.onsubmit = this.handleSubmit.bindAsEventListener(this);
},
handleSubmit : function(domevent)
{
// Locate elements that have an event manager (and therefore, validations)
// and let those validations execute, which may result in calls to recordError().
var event = new Tapestry.FormEvent(this.form);
this.form.getElements().each(function(element)
{
if (element.fieldEventManager != undefined)
{
event.field = element;
element.fieldEventManager.validateInput(event);
if (event.abort) throw $break;
}
});
// Allow observers to validate the form as a whole. The FormEvent will be visible
// as event.memo. The Form will not be submitted if event.result is set to false (it defaults
// to true).
this.form.fire(Tapestry.FORM_VALIDATE_EVENT, event);
if (! event.result)
{
Event.stop(domevent); // Should be domevent.stop(), but that fails under IE
}
else
{
this.form.fire(Tapestry.FORM_PREPARE_FOR_SUBMIT_EVENT);
}
return event.result;
}
};
Tapestry.FieldEventManager.prototype = {
initialize : function(field)
{
this.field = $(field);
field.fieldEventManager = this;
this.validators = [ ];
var id = field.id;
this.label = $(id + ':label');
this.icon = $(id + ':icon');
this.field.observe("blur", function()
{
var event = new Tapestry.FormEvent(this.field.form);
// This prevents the field from taking focus if there is an error.
event.firstError = false;
event.field = this.field;
this.validateInput(event);
}.bindAsEventListener(this));
},
// Adds a validator. acceptBlank is true if the validator should be invoked regardless of
// the value. Usually acceptBlank is false, meaning that the validator will be skipped if
// the field's value is blank. The validator itself is a function that is passed the
// field's value and the Tapestry.FormEvent object. When a validator invokes event.recordError(),
// any subsequent validators for that field are skipped.
addValidator : function(acceptBlank, validator)
{
this.validators.push([ acceptBlank, validator]);
},
// Removes decorations on the field and label (the "t-error" CSS class) and makes the icon
// invisible. A field that has special decoration needs will override this method.
removeDecorations : function()
{
this.field.removeClassName("t-error");
if (this.label)
this.label.removeClassName("t-error");
if (this.icon)
this.icon.hide();
if (this.errorPopup)
this.errorPopup.hide();
},
// Adds decorations to the field (including label and icon if present).
// event - the validation event
// message - error message
addDecorations : function(message)
{
this.field.addClassName("t-error");
if (this.label)
this.label.addClassName("t-error");
if (this.icon)
{
if (! this.icon.visible())
new Effect.Appear(this.icon);
}
if (this.errorPopup == undefined)
this.errorPopup = new Tapestry.ErrorPopup(this.field);
this.errorPopup.showMessage(message);
},
// Invoked from the Form's onsubmit event handler. Gets the fields value and invokes
// each validator (unless the value is blank) until a validator returns false. Validators
// should not modify the field's value.
validateInput : function(event)
{
if (this.field.disabled) return;
if (! this.field.isDeepVisible()) return;
var value = $F(event.field);
var isBlank = (value == '');
event.error = false;
this.validators.each(function(tuple)
{
var acceptBlank = tuple[0];
var validator = tuple[1];
if (acceptBlank || !isBlank)
{
validator(value, event);
// event.error is set by Tapestry.FormEvent.recordError().
if (event.error) throw $break;
}
});
if (! event.error)
this.removeDecorations();
}
};
// Wrappers around Prototype and Scriptaculous effects, invoked from Tapestry.Zone.show().
// All the functions of this object should have all-lowercase names.
Tapestry.ElementEffect = {
show : function(element)
{
element.show();
},
highlight : function(element)
{
new Effect.Highlight(element);
},
slidedown : function (element)
{
new Effect.SlideDown(element);
},
slideup : function(element)
{
new Effect.SlideUp(element);
},
fade : function(element)
{
new Effect.Fade(element);
}
};
Tapestry.Zone.prototype = {
// spec are the parameters for the Zone:
// trigger: required -- name or instance of link.
// element: required -- name or instance of div element to be shown, hidden and updated
// show: name of Tapestry.ElementEffect function used to reveal the zone if hidden
// update: name of Tapestry.ElementEffect function used to highlight the zone after it is updated
initialize: function(spec)
{
if (Object.isString(spec))
spec = { element: spec }
this.element = $(spec.element);
this.showFunc = Tapestry.ElementEffect[spec.show] || Tapestry.ElementEffect.show;
this.updateFunc = Tapestry.ElementEffect[spec.update] || Tapestry.ElementEffect.highlight;
// Link the div back to this zone.
this.element.zone = this;
// Look inside the Zone element for an element with the CSS class "t-zone-update".
// If present, then this is the elements whose content will be changed, rather
// then the entire Zone div. This allows a Zone div to contain "wrapper" markup
// (borders and such). Typically, such a Zone element will initially be invisible.
// The show and update functions apply to the Zone element, not the update element.
var updates = this.element.select(".t-zone-update");
this.updateElement = updates.length == 0 ? this.element : updates[0];
},
// Updates the content of the div controlled by this Zone, then
// invokes the show function (if not visible) or the update function (if visible).
show: function(content)
{
this.updateElement.update(content);
var func = this.element.visible() ? this.updateFunc : this.showFunc;
func.call(this, this.element);
}
};
// A class that managed an element (usually a <div>) that is conditionally visible and
// part of the form when visible.
Tapestry.FormFragment.prototype = {
initialize: function(spec)
{
if (Object.isString(spec))
spec = { element: spec };
this.element = $(spec.element);
this.element.formFragment = this;
this.hidden = $(spec.element + ":hidden");
this.showFunc = Tapestry.ElementEffect[spec.show] || Tapestry.ElementEffect.slidedown;
this.hideFunc = Tapestry.ElementEffect[spec.hide] || Tapestry.ElementEffect.slideup;
$(this.hidden.form).observe(Tapestry.FORM_PREPARE_FOR_SUBMIT_EVENT, function()
{
this.hidden.value = this.element.isDeepVisible();
}.bind(this));
},
hide : function()
{
if (this.element.visible())
this.hideFunc(this.element);
},
show : function()
{
if (! this.element.visible())
this.showFunc(this.element);
},
toggle : function()
{
this.setVisible(! this.element.visible());
},
setVisible : function(visible)
{
if (visible)
{
this.show();
return;
}
this.hide();
}
};
Tapestry.FormInjector.prototype = {
initialize: function(spec)
{
this.element = $(spec.element);
this.url = spec.url;
this.below = spec.below;
this.showFunc = Tapestry.ElementEffect[spec.show] || Tapestry.ElementEffect.highlight;
this.element.trigger = function()
{
var successHandler = function(transport)
{
var reply = transport.responseJSON;
// Clone the FormInjector element (usually a div)
// to create the new element, that gets inserted
// before or after the FormInjector's element.
var newElement = new Element(this.element.tagName, { 'class' : this.element.className });
// Insert the new element before or after the existing element.
var param = { };
var key = this.below ? "after" : "before";
param[key] = newElement;
// Add the new element with the downloaded content.
this.element.insert(param);
// Update the empty element with the content from the server
newElement.update(reply.content);
// Handle any scripting issues.
Tapestry.processScriptInReply(reply);
// Add some animation to reveal it all.
this.showFunc(newElement);
}.bind(this);
new Ajax.Request(this.url, { onSuccess : successHandler });
return false;
}.bind(this);
}
};
Tapestry.onDOMLoaded(Tapestry.onDomLoadedCallback);