package fr.openwide.core.wicket.more.util.listener;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import org.apache.wicket.Component;
import org.apache.wicket.MetaDataKey;
import org.apache.wicket.ajax.AbstractDefaultAjaxBehavior;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.AjaxRequestTarget.IJavaScriptResponse;
import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
import org.apache.wicket.application.IComponentOnAfterRenderListener;
import org.apache.wicket.application.IComponentOnBeforeRenderListener;
import org.apache.wicket.behavior.Behavior;
import org.apache.wicket.markup.head.IHeaderResponse;
import org.apache.wicket.markup.head.OnDomReadyHeaderItem;
import org.apache.wicket.markup.html.form.Check;
import org.apache.wicket.markup.html.form.CheckGroup;
import org.apache.wicket.markup.html.form.Form;
import org.apache.wicket.markup.html.form.FormComponent;
import org.apache.wicket.markup.html.form.Radio;
import org.apache.wicket.markup.html.form.RadioGroup;
import org.apache.wicket.markup.repeater.RefreshingView;
import org.apache.wicket.protocol.http.WebApplication;
import org.apache.wicket.util.visit.IVisit;
import org.apache.wicket.util.visit.IVisitor;
import org.wicketstuff.wiquery.core.javascript.JsStatement;
import org.wicketstuff.wiquery.core.javascript.JsUtils;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import fr.openwide.core.wicket.behavior.ClassAttributeAppender;
import fr.openwide.core.wicket.more.condition.Condition;
import fr.openwide.core.wicket.more.markup.html.basic.EnclosureContainer;
/**
* <p>Decorate fields by adding CSS class '.has-error' and traced it back to the related form-group, using Javascript.</p>
*
* <p>The listener must be initialized in the Wicket application configuration: {@code FormErrorDecoratorListener.init(this);}</p>
*/
public final class FormErrorDecoratorListener {
private FormErrorDecoratorListener() { }
public static void init(WebApplication application) {
application.getComponentPreOnBeforeRenderListeners().add(PRE_ON_BEFORE_RENDER_LISTENER);
application.getComponentPostOnBeforeRenderListeners().add(POST_ON_BEFORE_RENDER_LISTENER);
application.getComponentOnAfterRenderListeners().add(ON_AFTER_RENDER_LISTENER);
application.getAjaxRequestTargetListeners().add(AJAX_LISTENER);
FormProcessedListener.init(application);
}
private static final String HAS_ERROR_CSS_CLASS = "has-error";
private static final String HAS_ERROR_REMINDER_CSS_CLASS = "has-error-reminder";
private static final Behavior HAS_ERROR_BEHAVIOR = new ClassAttributeAppender(HAS_ERROR_CSS_CLASS) {
private static final long serialVersionUID = 1L;
@Override
public boolean isTemporary(Component component) {
/*
* Don't use this Wicket feature.
* "Temporary" behaviors are removed upon detach, and components within a
* RefreshingView are detached as part of the rendering process (when the view's items are all removed,
* then re-added).
* Thus "temporary" behaviors are never executed for components within a RefreshingView.
* Thus we use a IComponentOnAfterRenderListener for cleaning up this behavior.
*/
return true;
}
private Object readResolve() {
return HAS_ERROR_BEHAVIOR;
}
};
private static final Behavior PROPAGATE_HAS_ERROR_BEHAVIOR = new Behavior() {
private static final long serialVersionUID = -7997289335427913596L;
@Override
public boolean isTemporary(Component component) {
/*
* See HAS_ERROR_BEHAVIOR.isTemporary.
*/
return true;
}
@Override
public void renderHead(Component component, IHeaderResponse response) {
component.setOutputMarkupId(true);
StringBuilder sb = new StringBuilder();
sb
.append(new JsStatement().$(component, ".form-group, .form-decorator-error-group")
.removeClass(HAS_ERROR_CSS_CLASS)
.render()
)
.append(new JsStatement().$(component, ".has-error")
.chain("parentsUntil", JsUtils.quotes("#" + component.getMarkupId()), JsUtils.quotes(".form-group, .form-decorator-error-group"))
.addClass(HAS_ERROR_CSS_CLASS)
.render()
)
// Les .form-decorator-error-reminder doivent prendre une apparence particulière en cas d'erreur,
// mais sans impacter l'apparence des sous-éléments comme le ferait un .has-error
.append(new JsStatement().$(component, ".form-decorator-error-reminder")
.removeClass(HAS_ERROR_REMINDER_CSS_CLASS)
.render()
)
.append(new JsStatement().$(component, "." + HAS_ERROR_CSS_CLASS)
.chain("parentsUntil", JsUtils.quotes("#" + component.getMarkupId()), JsUtils.quotes(".form-decorator-error-reminder"))
.addClass(HAS_ERROR_REMINDER_CSS_CLASS)
.render()
);
response.render(OnDomReadyHeaderItem.forScript(sb.toString()));
}
};
private static final MetaDataKey<Serializable> HAS_ERROR = new MetaDataKey<Serializable>() {
private static final long serialVersionUID = 1L;
private Object readResolve() {
return HAS_ERROR;
}
};
private static final MetaDataKey<Serializable> FORM_GROUP = new MetaDataKey<Serializable>() {
private static final long serialVersionUID = 1L;
private Object readResolve() {
return FORM_GROUP;
}
};
/*
* First pass: make sure we won't lose information due to some RefreshingView weirdness.
*
* RefreshingView triggers a detach on its children (and grandchildren and so on) when it
* executes onBeforeRender (because it calls onPopulate, which removes all children, which detaches
* each child).
* Since detaching a child very likely will remove temporary behaviors (such as HAS_ERROR_BEHAVIOR)
* and will reset error messages (which will make FormComponents valid again), we have to intervene
* before these detaches and save the information that some components are errored.
*/
private static final IComponentOnBeforeRenderListener PRE_ON_BEFORE_RENDER_LISTENER =
new IComponentOnBeforeRenderListener() {
@Override
public void onBeforeRender(Component component) {
if (component instanceof RefreshingView<?>) {
RefreshingView<?> form = (RefreshingView<?>)component;
form.visitChildren(FormComponent.class, new IVisitor<FormComponent<?>, Void>() {
@Override
public void component(FormComponent<?> formComponent, IVisit<Void> visit) {
if (hasError(formComponent)) {
formComponent.setMetaData(HAS_ERROR, HAS_ERROR);
}
}
});
}
}
};
/*
* Second pass: add the HAS_ERROR_BEHAVIOR.
*
* Here we are sure that, even if the components to decorate are nested deep in the current component's
* child hierarchy, and even if these components to decorate have a parent RefreshingView (see above),
* those RefreshingViews already have been populated and thus will not trigger a detach that would remove
* temporary beavhiors.
*/
private static final IComponentOnBeforeRenderListener POST_ON_BEFORE_RENDER_LISTENER =
new IComponentOnBeforeRenderListener() {
@Override
public void onBeforeRender(Component component) {
if (component instanceof FormComponent) {
FormComponent<?> formComponent = (FormComponent<?>)component;
if (hasError(formComponent)) {
formComponent.setMetaData(HAS_ERROR, null);
for (Component componentToMarkWithError : getComponentsToDecorateWithCSS(formComponent)) {
if (!componentToMarkWithError.getBehaviors().contains(HAS_ERROR_BEHAVIOR)) {
componentToMarkWithError.add(HAS_ERROR_BEHAVIOR);
}
}
}
}
}
};
private static final IComponentOnAfterRenderListener ON_AFTER_RENDER_LISTENER =
new IComponentOnAfterRenderListener() {
@Override
public void onAfterRender(Component component) {
if (component instanceof RefreshingView<?>) {
// Nettoyage des metadonnées ajoutées dans onBeforeRender
RefreshingView<?> form = (RefreshingView<?>)component;
form.visitChildren(FormComponent.class, new IVisitor<FormComponent<?>, Void>() {
@Override
public void component(FormComponent<?> formComponent, IVisit<Void> visit) {
formComponent.setMetaData(HAS_ERROR, null);
}
});
}
}
};
private static final AjaxRequestTarget.IListener AJAX_LISTENER =
new AjaxRequestTarget.IListener() {
@Override
public void updateAjaxAttributes(AbstractDefaultAjaxBehavior behavior, AjaxRequestAttributes attributes) {
// Rien à faire de particulier.
}
@Override
public void onBeforeRespond(Map<String, Component> map, AjaxRequestTarget target) {
target.getPage().visitChildren(FormComponent.class, new AjaxRenderingVisitor(target));
}
@Override
public void onAfterRespond(Map<String, Component> map, IJavaScriptResponse response) {
// Rien à faire de particulier après la réponse.
}
};
private static final class AjaxRenderingVisitor implements IVisitor<FormComponent<?>, Void> {
private final AjaxRequestTarget target;
public AjaxRenderingVisitor(AjaxRequestTarget target) {
this.target = target;
}
@Override
public void component(FormComponent<?> formComponent, IVisit<Void> visit) {
if (hasError(formComponent)) {
Form<?> formToRefresh = FormProcessedListener.findProcessedForm(formComponent);
// If the form has not been processed, isValid() always returns false, but
// in our case we'd like to consider the formComponent as valid.
if (formToRefresh != null) {
target.add(formToRefresh);
if (!formToRefresh.getBehaviors().contains(PROPAGATE_HAS_ERROR_BEHAVIOR)) {
formToRefresh.add(PROPAGATE_HAS_ERROR_BEHAVIOR);
}
}
}
}
}
private static boolean hasError(FormComponent<?> formComponent) {
return formComponent.getMetaData(HAS_ERROR) != null || !formComponent.isValid();
}
public static void markWithError(FormComponent<?> formComponent) {
formComponent.setMetaData(HAS_ERROR, HAS_ERROR);
}
private static List<? extends Component> getComponentsToDecorateWithCSS(FormComponent<?> formComponent) {
if (formComponent.getParent().getMetaData(FORM_GROUP) != null) {
return ImmutableList.of(formComponent.getParent());
} else if (formComponent instanceof RadioGroup) {
return collect(formComponent, Radio.class);
} else if (formComponent instanceof CheckGroup) {
return collect(formComponent, Check.class);
} else {
return ImmutableList.of(formComponent);
}
}
private static <T extends Component> List<T> collect(FormComponent<?> formComponent, Class<T> clazz) {
final List<T> result = Lists.newArrayList();
formComponent.visitChildren(clazz, new IVisitor<T, Void>() {
@Override
public void component(T object, IVisit<Void> visit) {
result.add(object);
}
});
return result;
}
/**
* Pour le moment, on traite tout en JavaScript automatiquement donc ce n'est pas la peine de wrapper les form-group.
*
* On le laisse quand même là vu qu'on a tout géré correctement pour ce cas et que ça pourra peut-être servir dans
* des cas très spécifiques.
*/
public static Component wrapFormGroup(FormComponent<?> formComponent) {
EnclosureContainer formGroup = new EnclosureContainer(formComponent.getId() + "FormGroup");
formGroup.add(formComponent);
formGroup.condition(Condition.componentVisible(formComponent));
formGroup.setMetaData(FORM_GROUP, FORM_GROUP);
return formGroup;
}
public static void propagateHasError(Form<?> form) {
form.add(PROPAGATE_HAS_ERROR_BEHAVIOR);
}
}