/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You 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. */ package org.apache._wicket.ajax; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Iterator; import java.util.List; import org.apache._wicket.ajax.json.JSONArray; import org.apache._wicket.ajax.json.JSONObject; import org.apache.wicket.Application; import org.apache.wicket.Component; import org.apache.wicket.IRequestTarget; import org.apache.wicket.MarkupContainer; import org.apache.wicket.Page; import org.apache.wicket.RequestCycle; import org.apache.wicket.ResourceReference; import org.apache.wicket.Response; import org.apache.wicket.WicketRuntimeException; import org.apache.wicket.behavior.IBehavior; import org.apache.wicket.markup.html.IHeaderResponse; import org.apache.wicket.markup.html.internal.HeaderResponse; import org.apache.wicket.markup.html.internal.HtmlHeaderContainer; import org.apache.wicket.markup.parser.filter.HtmlHeaderSectionHandler; import org.apache.wicket.markup.repeater.AbstractRepeater; import org.apache.wicket.protocol.http.WebResponse; import org.apache.wicket.response.StringResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * A request target that produces ajax response envelopes used on the client side to update * component markup as well as evaluate arbitrary javascript. *

* A component whose markup needs to be updated should be added to this target via * AjaxRequestTarget#addComponent(Component) method. Its body will be rendered and added to the * envelope when the target is processed, and refreshed on the client side when the ajax response is * received. *

* It is important that the component whose markup needs to be updated contains an id attribute in * the generated markup that is equal to the value retrieved from Component#getMarkupId(). This can * be accomplished by either setting the id attribute in the html template, or using an attribute * modifier that will add the attribute with value Component#getMarkupId() to the tag ( such as * MarkupIdSetter ) *

* Any javascript that needs to be evaluated on the client side can be added using * AjaxRequestTarget#append/prependJavascript(String). For example, this feature can be useful when * it is desirable to link component update with some javascript effects. *

* The target provides a listener interface {@link IListener} that can be used to add code that * responds to various target events by adding listeners via {@link #addListener(IListener)} * * @since 1.2 * * @author Igor Vaynberg (ivaynberg) * @author Eelco Hillenius * @author Matej Knopp */ public class AjaxRequestTarget implements IRequestTarget { private AjaxRequestTarget() { this.component = null; this.page = null; this.behaviorIndex = -1; } /** * An {@link AjaxRequestTarget} listener that can be used to respond to various target-related * events */ public static interface IListener { /** * Triggered before ajax request target begins its response cycle * * @param components * read-only list of component entries already added to the target * @param target * the target itself. Could be used to add components or to append/prepend * javascript * */ public void onBeforeRespond(List components, AjaxRequestTarget target); /** * Triggered after ajax request target is notify with its response cycle. At this point only * additional javascript can be output to the response using the provided * {@link IJavascriptResponse} object * * NOTE: During this stage of processing any calls to target that manipulate the response * (adding components, javascript) will have no effect * * @param components * read-only list of component entries added to the target * @param response * response object that can be used to output javascript */ public void onAfterRespond(List components, IJavascriptResponse response); } /** * An ajax javascript response that allows users to add javascript to be executed on the client * side * * @author ivaynberg */ public static interface IJavascriptResponse { /** * Adds more javascript to the ajax response that will be executed on the client side * * @param script * javascript */ public void addJavascript(String script); } private final Page page; private final Component component; private final int behaviorIndex; private final List entries = new ArrayList(); private final List prependJavascripts = new ArrayList(); private final List appendJavascripts = new ArrayList(); private final List domReadyJavascripts = new ArrayList(); private final List listeners = new ArrayList(); private static final Logger log = LoggerFactory.getLogger(AjaxRequestTarget.class); // whether a header contribution is being rendered private boolean headerRendering = false; private HtmlHeaderContainer header = null; private IHeaderResponse headerResponse; /** * Construct. * * @param component * @param behaviorIndex */ public AjaxRequestTarget(Component component, int behaviorIndex) { if (component == null) { throw new IllegalArgumentException("Argument 'component' may not be null."); } page = component.getPage(); if (page == null) { throw new IllegalArgumentException("Component must belong to a page."); } this.component = component; this.behaviorIndex = behaviorIndex; } public void detach(RequestCycle requestCycle) { if (!entries.isEmpty()) { entries.iterator().next().getComponent().getPage().detach(); } else { page.detach(); } } /** * Returns component that has behavior which initiated this Ajax request. * * @return component */ public Component getComponent() { return component; } /** * Entry for a single component. Allows to specify custom javascript handlers executed before * and after replacing the component. Also the actual component replacement can be overriden. * * @author Matej Knopp */ public static class ComponentEntry { private final Component component; private String beforeReplaceJavascript; private String afterReplaceJavascript; private String replaceJavascript; /** * Construct. * * @param component */ public ComponentEntry(Component component) { this.component = component; } /** * Construct. * * @param entry * entry to copy */ public ComponentEntry(ComponentEntry entry) { this.component = entry.component; this.beforeReplaceJavascript = entry.beforeReplaceJavascript; this.afterReplaceJavascript = entry.afterReplaceJavascript; this.replaceJavascript = entry.replaceJavascript; } /** * Returns component that will be updated. * * @return component */ public Component getComponent() { return component; } /** * Sets the javascript executed right before replacing the component. *

* The javascript can use following variables: *

*
requestQueueItem
*
RequestQueueItem instance for current request
*
componentId
*
MarkupId of component that is about to be replaced *
notify
*
Method that javascript needs to execute after it has finished. Note that it is * mandatory to call this method otherwise the processing pipeline will stop
*
* * @param beforeReplaceJavascript * the javascript */ public void setBeforeReplaceJavascript(String beforeReplaceJavascript) { this.beforeReplaceJavascript = beforeReplaceJavascript; } /** * Returns the javascript executed before replacing the component. * * @see #setBeforeReplaceJavascript(String) * @return javascript */ public String getBeforeReplaceJavascript() { return beforeReplaceJavascript; } /** * Sets the javascript executed right after replacing the component. *

* The javascript can use following variables: *

*
requestQueueItem
*
RequestQueueItem instance for current request
*
componentId
*
MarkupId of component that has been replaced *
insertedElements
*
Array of newly inserted elements
*
notify
*
Method that javascript needs to execute after it has finished. Note that it is * mandatory to call this method otherwise the processing pipeline will stop
*
* * @param afterReplaceJavascript * the javascript */ public void setAfterReplaceJavascript(String afterReplaceJavascript) { this.afterReplaceJavascript = afterReplaceJavascript; } /** * Returns the javascript executed after replacing the component. * * @see #setAfterReplaceJavascript(String) * @return javascript */ public String getAfterReplaceJavascript() { return afterReplaceJavascript; } /** * Sets the javascript executed to replace the component. *

* The javascript can use following variables: *

*
requestQueueItem
*
RequestQueueItem instance for current request
*
componentId
*
MarkupId of component that has been replaced *
markup
*
The new markup that should replace current markup
*
notify
*
Method that javascript needs to execute after the component has been replaced. Note * that it is mandatory to call this method otherwise the processing pipeline will stop. * Array of newly inserted elements should be passed as argument to the notify method.
*
* * An example javascript: * *
		 * var element = W.$(componentId);
		 * var insertedElements = W.replaceOuterHtml(element, markup);
		 * notify(insertedElements);
		 * 
		 * 
* * @param replaceJavascript * the javascript */ public void setReplaceJavascript(String replaceJavascript) { this.replaceJavascript = replaceJavascript; } /** * Returns the javascript executed to replace component. * * @see #setReplaceJavascript(String) * @return javsacript executed to replace component */ public String getReplaceJavascript() { return replaceJavascript; } } private static class UnmodifiableComponentEntry extends ComponentEntry { public UnmodifiableComponentEntry(ComponentEntry entry) { super(entry); } @Override public void setAfterReplaceJavascript(String javascript) { throw new UnsupportedOperationException("ComponentEntry is can not be modified."); } @Override public void setBeforeReplaceJavascript(String javascript) { throw new UnsupportedOperationException("ComponentEntry is can not be modified."); } @Override public void setReplaceJavascript(String javascript) { throw new UnsupportedOperationException("ComponentEntry is can not be modified."); } } private boolean isParent(Component parent, Component component) { Component p = component.getParent(); while (p != null && p != parent) { p = p.getParent(); } return p == null; } private void checkComponent(Component component) { if (component == null) { throw new IllegalArgumentException("Component may not be null."); } else if (component instanceof Page) { throw new IllegalArgumentException("Component cannot be a page"); } else if (!component.getOutputMarkupId()) { throw new IllegalStateException("Component " + component.getClass().getName() + " must have setOuputMarkupId set in order to be updated via ajax."); } else if (component.getRenderBodyOnly()) { throw new IllegalStateException("Component " + component.getClass().getName() + " must not have setRenderBodyOnly set in order to be updated via ajax."); } else if (component instanceof AbstractRepeater) { throw new IllegalArgumentException( "Component " + component.getClass().getName() + " has been added to the target. This component is a repeater and cannot be repainted via ajax directly. Instead add its parent or another markup container higher in the hierarchy."); } } /** * Adds a component entry to the list of components to be rendered * * @param entry * component entry to be rendered * * @return true if the component was added, false if the component * or some of it's parents is already in the list */ public boolean addComponent(ComponentEntry entry) { if (entry == null) { throw new IllegalArgumentException("Argument 'entry' may not be null."); } final Component component = entry.getComponent(); checkComponent(component); for (ComponentEntry e : entries) { if (e.getComponent() == component) { return false; } // check if component's parent is already in queue else if (isParent(e.getComponent(), component)) { return false; } // check if new component is parent of existing component else if (isParent(component, e.getComponent())) { entries.remove(e); break; } } entries.add(entry); return true; } /** * Adds a component to the list of components to be rendered * * @param component * component to be rendered * * @return true if the component was added, false if the component * or some of it's parents is already in the list */ public boolean addComponent(Component component) { if (component == null) { throw new IllegalArgumentException("Argument 'component' may not be null."); } return addComponent(new ComponentEntry(component)); } private static class JavascriptEntry { private final String javascript; private final boolean async; public JavascriptEntry(String javascript, boolean async) { this.javascript = javascript; this.async = async; } public String getJavascript() { return javascript; } public boolean isAsync() { return async; } }; /** * Adds javascript that will be evaluated on the client side before components are replaced *

* The javascript can use following variables: *

*
requestQueueItem
*
RequestQueueItem instance for current request
*
notify
*
Must be called for asynchronous javascript *
* * @param javascript * javascript to be evaluated * @param async * indicates if the javascript should be evaluated asynchrously. If * async is true, the javascript must invoke the * notify function that it gets passed for the processing queue to * continue. */ public void prependJavascript(String javascript, boolean async) { if (javascript == null) { throw new IllegalArgumentException("Argument 'javascript' may not be null."); } prependJavascripts.add(new JavascriptEntry(javascript, async)); } /** * Adds javascript that will be evaluated on the client side before components are replaced. The * javascript will be executed synchronously which means that the processing queue will be held * until the javascript finishes. *

* The javascript can use following variables: *

*
requestQueueItem
*
RequestQueueItem instance for current request
*
* * @param javascript * javascript to be evaluated */ public void prependJavascript(String javascript) { prependJavascript(javascript, false); } /** * Adds javascript that will be evaluated on the client side after components are replaced *

* The javascript can use following variables: *

*
requestQueueItem
*
RequestQueueItem instance for current request
*
notify
*
Must be called for asynchronous javascript *
* * @param javascript * javascript to be evaluated * @param async * indicates if the javascript should be evaluated asynchrously. If * async is true, the javascript must invoke the * notify function that it gets passed for the processing queue to * continue. */ public void appendJavascript(String javascript, boolean async) { if (javascript == null) { throw new IllegalArgumentException("Argument 'javascript' may not be null."); } appendJavascripts.add(new JavascriptEntry(javascript, async)); } /** * Adds javascript that will be evaluated on the client side after components are replaced. The * javascript will be executed synchronously which means that the processing queue will be held * until the javascript finishes. *

* The javascript can use following variables: *

*
requestQueueItem
*
RequestQueueItem instance for current request
*
* * @param javascript * javascript to be evaluated */ public void appendJavascript(String javascript) { appendJavascript(javascript, false); } /** * Adds a listener to this target * * @param listener */ public void addListener(IListener listener) { if (listener == null) { throw new IllegalArgumentException("Argument `listener` cannot be null"); } listeners.add(listener); } private List entriesCopy() { List list = new ArrayList(entries.size()); for (ComponentEntry e : entries) { list.add(new UnmodifiableComponentEntry(e)); } return Collections.unmodifiableList(list); } private void fireOnBeforeRespondListeners(List entries) { if (!listeners.isEmpty()) { for (IListener l : listeners) { l.onBeforeRespond(entries, this); } } } private void fireOnAfterRespondListeners(List entries) { // invoke onafterresponse event on listeners if (!(listeners.isEmpty())) { // create response that will be used by listeners to append // javascript final IJavascriptResponse jsresponse = new IJavascriptResponse() { public void addJavascript(String script) { appendJavascript(script, false); } }; for (IListener listener : listeners) { listener.onAfterRespond(entries, jsresponse); } } } /** * Header response for an ajax request. * * @author Matej Knopp */ private class AjaxHeaderResponse extends HeaderResponse { private static final long serialVersionUID = 1L; private void checkHeaderRendering() { if (headerRendering == false) { throw new WicketRuntimeException( "Only methods that can be called on IHeaderResponse outside renderHead() are renderOnLoadJavascript and renderOnDomReadyJavascript"); } } @Override public void renderCSSReference(ResourceReference reference, String media) { checkHeaderRendering(); super.renderCSSReference(reference, media); } @Override public void renderCSSReference(String url) { checkHeaderRendering(); super.renderCSSReference(url); } @Override public void renderCSSReference(String url, String media) { checkHeaderRendering(); super.renderCSSReference(url, media); } @Override public void renderJavascript(CharSequence javascript, String id) { checkHeaderRendering(); super.renderJavascript(javascript, id); } @Override public void renderCSSReference(ResourceReference reference) { checkHeaderRendering(); super.renderCSSReference(reference); } @Override public void renderJavascriptReference(ResourceReference reference) { checkHeaderRendering(); super.renderJavascriptReference(reference); } @Override public void renderJavascriptReference(ResourceReference reference, String id) { checkHeaderRendering(); super.renderJavascriptReference(reference, id); } @Override public void renderJavascriptReference(String url) { checkHeaderRendering(); super.renderJavascriptReference(url); } @Override public void renderJavascriptReference(String url, String id) { checkHeaderRendering(); super.renderJavascriptReference(url, id); } @Override public void renderString(CharSequence string) { checkHeaderRendering(); super.renderString(string); } /** * Construct. */ public AjaxHeaderResponse() { } /** * * @see org.apache.wicket.markup.html.internal.HeaderResponse#renderOnDomReadyJavascript(java.lang.String) */ @Override public void renderOnDomReadyJavascript(String javascript) { List token = Arrays.asList(new String[] { "javascript-event", "window", "domready", javascript }); if (wasRendered(token) == false) { domReadyJavascripts.add(new JavascriptEntry(javascript, false)); markRendered(token); } } /** * * @see org.apache.wicket.markup.html.internal.HeaderResponse#renderOnLoadJavascript(java.lang.String) */ @Override public void renderOnLoadJavascript(String javascript) { List token = Arrays.asList(new String[] { "javascript-event", "window", "load", javascript }); if (wasRendered(token) == false) { // execute the javascript after all other scripts are executed appendJavascript(javascript, false); markRendered(token); } } /** * * @see org.apache.wicket.markup.html.internal.HeaderResponse#getRealResponse() */ @Override protected Response getRealResponse() { return RequestCycle.get().getResponse(); } }; /** * Returns the header response associated with current AjaxRequestTarget. * * Beware that only renderOnDomReadyJavascript and renderOnLoadJavascript can be called outside * the renderHeader(IHeaderResponse response) method. Calls to other render** methods will * result in an exception being thrown. * * @return header response */ public IHeaderResponse getHeaderResponse() { if (headerResponse == null) { headerResponse = new AjaxHeaderResponse(); } return headerResponse; } /** * Header container component for ajax header contributions * * @author Matej Knopp */ private static class AjaxHtmlHeaderContainer extends HtmlHeaderContainer { private static final long serialVersionUID = 1L; /** * Construct. * * @param id * @param target */ public AjaxHtmlHeaderContainer(String id, AjaxRequestTarget target) { super(id); this.target = target; } /** * * @see org.apache.wicket.markup.html.internal.HtmlHeaderContainer#newHeaderResponse() */ @Override protected IHeaderResponse newHeaderResponse() { return target.getHeaderResponse(); } private final transient AjaxRequestTarget target; }; /** * * @param response * @param component */ private void respondHeaderContribution(final Response response, final Component component) { // render the head of component and all it's children component.renderHead(header); if (component instanceof MarkupContainer) { ((MarkupContainer)component).visitChildren(new Component.IVisitor() { public Object component(Component component) { if (component.isVisible()) { component.renderHead(header); return CONTINUE_TRAVERSAL; } else { return CONTINUE_TRAVERSAL_BUT_DONT_GO_DEEPER; } } }); } } private String respondHeaderContribution() { headerRendering = true; // create the htmlheadercontainer if needed if (header == null) { header = new AjaxHtmlHeaderContainer(HtmlHeaderSectionHandler.HEADER_ID, this); final Page page = component.getPage(); page.addOrReplace(header); } // save old response, set new StringResponse stringResponse = new StringResponse(); Response oldResponse = RequestCycle.get().setResponse(stringResponse); for (ComponentEntry e : entries) { respondHeaderContribution(stringResponse, component); } // revert to old response RequestCycle.get().setResponse(oldResponse); headerRendering = false; return stringResponse.toString(); } private void prepareRender() { for (Iterator i = entries.iterator(); i.hasNext();) { ComponentEntry entry = i.next(); Component component = entry.getComponent(); final Page page = (Page)component.findParent(Page.class); if (page == null) { // dont throw an exception but just ignore this component, somehow // it got removed from the page. log.debug("component: " + component + " with markupid: " + component.getMarkupId() + " not rendered because it was already removed from page"); i.remove(); continue; } checkComponent(component); try { component.prepareForRender(); } catch (RuntimeException e) { try { component.afterRender(); } catch (RuntimeException e2) { // ignore this one could be a result off. } throw e; } } } private String renderComponent(Component component) { StringResponse stringResponse = new StringResponse(); Response originalResponse = RequestCycle.get().setResponse(stringResponse); page.startComponentRender(component); component.renderComponent(); page.endComponentRender(component); RequestCycle.get().setResponse(originalResponse); return stringResponse.toString(); } private JSONObject renderComponentEntry(ComponentEntry componentEntry) { JSONObject object = new JSONObject(); Component component = componentEntry.getComponent(); object.put("componentId", component.getMarkupId()); object.put("beforeReplaceJavascript", componentEntry.getBeforeReplaceJavascript()); object.put("afterReplaceJavascript", componentEntry.getAfterReplaceJavascript()); object.put("replaceJavascript", componentEntry.getReplaceJavascript()); object.put("markup", renderComponent(component)); return object; } private JSONObject renderJavascriptEntry(JavascriptEntry javascriptEntry) { JSONObject object = new JSONObject(); object.put("async", javascriptEntry.isAsync()); object.put("javascript", javascriptEntry.getJavascript()); return object; } public void respond(RequestCycle requestCycle) { IBehavior behavior = component.getBehaviors().get(behaviorIndex); if (behavior instanceof AjaxBehavior == false) { throw new WicketRuntimeException("Behavior must be instance of AjaxBehavior."); } ((AjaxBehavior)behavior).respond(this); List entriesCopy = entriesCopy(); fireOnBeforeRespondListeners(entriesCopy); JSONObject response = new JSONObject(); JSONArray components = new JSONArray(); response.put("components", components); if (!entries.isEmpty()) { prepareRender(); response.put("header", respondHeaderContribution()); for (ComponentEntry entry : entries) { components.put(renderComponentEntry(entry)); } } fireOnAfterRespondListeners(entries); JSONArray prependJavascripts = new JSONArray(); response.put("prependJavascript", prependJavascripts); for (JavascriptEntry e : this.prependJavascripts) { prependJavascripts.put(renderJavascriptEntry(e)); } JSONArray appendJavascripts = new JSONArray(); response.put("appendJavascript", appendJavascripts); for (JavascriptEntry e : this.domReadyJavascripts) { appendJavascripts.put(renderJavascriptEntry(e)); } for (JavascriptEntry e : this.appendJavascripts) { appendJavascripts.put(renderJavascriptEntry(e)); } WebResponse webResponse = (WebResponse)requestCycle.getResponse(); prepareResponse(webResponse); webResponse.write("if (false) ("); webResponse.write(response.toString()); webResponse.write(")"); } /** * Renders a redirect response to the response object. This is used to send the redirect to * client outside {@link AjaxRequestTarget#respond(RequestCycle)}. * * @param response * @param url */ public static void sendRedirect(WebResponse response, String url) { JSONObject object = new JSONObject(); object.put("redirect", url); try { response.getHttpServletResponse().getWriter().write("if (false) ("); response.getHttpServletResponse().getWriter().write(object.toString()); response.getHttpServletResponse().getWriter().write(")"); } catch (Exception e) { log.error("Error sending redirect", e); } } private void prepareResponse(WebResponse response) { final Application app = Application.get(); // Determine encoding final String encoding = app.getRequestCycleSettings().getResponseRequestEncoding(); // Set content type based on markup type for page response.setCharacterEncoding(encoding); response.setContentType("text/xml; charset=" + encoding); // Make sure it is not cached by a client response.setHeader("Expires", "Mon, 26 Jul 1997 05:00:00 GMT"); response.setHeader("Cache-Control", "no-cache, must-revalidate"); response.setHeader("Pragma", "no-cache"); } /** * Static method that returns current {@link AjaxRequestTarget} or null of no * {@link AjaxRequestTarget} is available. * * @return {@link AjaxRequestTarget} instance if current request is an Ajax request, * null otherwise. */ public static AjaxRequestTarget get() { final RequestCycle requestCycle = RequestCycle.get(); if (requestCycle != null) { if (requestCycle.getRequestTarget() instanceof AjaxRequestTarget) { return (AjaxRequestTarget)requestCycle.getRequestTarget(); } } return null; } /** * Dummy AJAX request target instance used by {@link AjaxBehavior} to generate AJAX URL prefix. */ public static final AjaxRequestTarget DUMMY = new AjaxRequestTarget(); }