/** * Copyright 2014 55 Minutes (http://www.55minutes.com) * * 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. */ package fiftyfive.wicket.js; import java.util.HashMap; import java.util.Map; import fiftyfive.wicket.js.locator.DependencyCollection; import fiftyfive.wicket.js.locator.JavaScriptDependencyLocator; import org.apache.wicket.Component; import org.apache.wicket.WicketRuntimeException; import org.apache.wicket.javascript.DefaultJavaScriptCompressor; import org.apache.wicket.javascript.IJavaScriptCompressor; import org.apache.wicket.markup.html.IHeaderResponse; import org.apache.wicket.request.resource.ResourceReference; import org.apache.wicket.util.string.interpolator.PropertyVariableInterpolator; import org.apache.wicket.util.template.PackageTextTemplate; import org.apache.wicket.util.template.TextTemplate; /** * Injects DOM-ready Javascript into the {@code <head>} using the * interpolated contents of a separate JavaScript template. * The code in the associated JavaScript file will run when the page loads, * and also every time the component to which it is attached is repainted via * Wicket ajax. * <p> * <b>This behavior will cause jQuery to be added to the <head> if it * is not there already.</b> Your script can rely on the {@code jQuery} * object being available. * <p> * Internally Wicket's {@link PropertyVariableInterpolator} is used to * perform substitutions in the template using the * <code>${propertyExpression}</code> syntax. Two keys are available for you * to use in your templates: * <ul> * <li><b>{@code component}</b> is the component to which the * {@code DomReadyTemplate} is bound. The most common use is to obtain * the markup ID of the component like this: * <code>${component.markupId}</code>.</li> * <li><b>{@code behavior}</b> is the {@code DomReadyTemplate} object itself. * If you extend {@code DomReadyTemplate} to create a custom JavaScript * behavior, you can declare properties on your subclass that can then be * used in the JavaScript template like this: * <code>${behavior.propertyName}</code>.</li> * </ul> * <p> * {@code DomReadyTemplate} is ideal for rich Wicket components that are * tightly integrated with associated JavaScript or have complex JavaScript * initialization code. * <p> * Other benefits: * <ul> * <li>Dependency resolution is performed on the associated JavaScript file. * Your associated JavaScript file can declare the * libraries and files it needs to function, and these dependencies will be * included in the {@code <head>} automatically. * Refer to the <a href="package-summary.html#dependency-resolution">dependency * resolution guide</a> for more information.</li> * <li>Just like a subclass of a panel can override the HTML of its parent, * a subclass of a panel using {@code DomReadyTemplate} can override the * JavaScript. For example, if {@code MyPanel} is subclassed as * {@code MyExtendedPanel}, the associated JavaScript will be loaded * from {@code MyExtendedPanel.js}, and only if that doesn't exist will * the original {@code MyPanel.js} be used. This allows you to subclass * an existing component, completely customize the JavaScript of that * component, but leave the Java code (and HTML) untouched.</li> * </li> * <p> * Here is a trival example: * <pre class="example"> * <b>// HelloPanel.java</b> * // Panel that replaces its contents with "Hello" on DOM-ready * public class HelloPanel extends DomReadyTemplatePanel * { * public HelloPanel(String id) * { * super(id); * add(new DomReadyTemplate(getClass())); * } * }</pre> * <pre class="example"> * <b>// HelloPanel.js</b> * // Depends on jQuery * //= require jquery * jQuery("#${component.markupId}").text("Hello");</pre> * <p> * When this panel is used on a page, the {@code <head>} will automatically * gain code like this: * <pre class="example"> * <script type="text/javascript"> * jQuery(function(){ * jQuery("#panel1").text("Hello"); * }); * </script></pre> * * @since 2.0 */ public class DomReadyTemplate extends AbstractJavaScriptContribution { private static final DefaultJavaScriptCompressor DEFAULT_COMPRESSOR = new DefaultJavaScriptCompressor(); private Class<?> templateLocation; private transient DependencyCollection dependencies; private transient ResourceReference template; private transient String readyScript; /** * Constructs a {@code DomReadyTemplate} to be used with a panel. * Like this: * <pre class="example"> * public class MyPanel extends Panel * { * public MyPanel(String id) * { * super(id); * add(new DomReadyTemplate(getClass())); * } * }</pre> * * @param templateLocation The class that will be used to resolve the * location of the JavaScript template. For * example, if the class is {@code MyClass.class}, * the template will be loaded from * {@code MyClass.js} in the same classpath * location. In most cases you will pass the result * of {@code getClass()} so it is possible for * subclasses to contribute their own template. */ public DomReadyTemplate(Class<?> templateLocation) { internalInit(templateLocation); } /** * Constructor for subclasses that wish to define a custom template-based * behavior. In this case the template will be loaded from a JavaScript * file that is the same name as the {@code DomReadyTemplate} subclass. * <p> * In other words: * <pre class="example"> * // JavaScript will be loaded from MyCustomBehavior.js * public class MyCustomBehavior extends DomReadyTemplate * { * public MyCustomBehavior() * { * super(); * } * }</pre> */ protected DomReadyTemplate() { internalInit(getClass()); } private void internalInit(Class<?> templateLocation) { this.templateLocation = templateLocation; } /** * Returns the {@link IJavaScriptCompressor} that will be used to * compress the JavaScript before it is added to the {@code <head>}. * By default this returns an instance of * {@code DefaultJavaScriptCompressor}. */ protected IJavaScriptCompressor getCompressor() { return DEFAULT_COMPRESSOR; } /** * Stores a reference to the component so that it can be used to * substitute <code>${component}</code> expressions in the JavaScript * template. * <p> * Also calls {@code setOutputMarkupId(true)} on the component. We do * this because the most common technique for integrating JavaScript * handlers with a component is by using its markup ID. */ @Override public void bind(Component component) { super.bind(component); component.setOutputMarkupId(true); } /** * Releases internal caches. */ @Override public void detach(Component component) { super.detach(component); this.dependencies = null; this.template = null; this.readyScript = null; } /** * Renders script src tags for all the dependencies that were discovered * via <a href="http://getsprockets.org">Sprockets</a> syntax in the * JavaScript template, plus a DOM-ready section containing the contents * of the template after variable substitutions have been performed. */ @Override public void renderHead(Component comp, IHeaderResponse response) { if(null == this.dependencies) load(comp); renderDependencies(response, this.dependencies, this.template); renderDomReady(response, this.readyScript); } /** * Loads the JavaScript template, determines its dependencies, and then * uses PropertyVariableInterpolator to perform variable substitutions. * The results are cached in member variables that will be cleared when * detach() is called. */ private void load(Component comp) { JavaScriptDependencyLocator locator = settings().getLocator(); this.dependencies = new DependencyCollection(); locator.findAssociatedScripts(this.templateLocation, this.dependencies); this.template = this.dependencies.getRootReference(); if(null == this.template) { throw new WicketRuntimeException(String.format( "Failed to locate JavaScript template for %s. The JavaScript file must be " + "the same name as the class but with a '.js' extension and be present in " + "the same classpath location.", this.templateLocation)); } TextTemplate tt = new PackageTextTemplate( this.template.getScope(), this.template.getName(), "application/javascript", settings().getEncoding() ); Map<String,Object> map = new HashMap<String,Object>(); map.put("component", comp); map.put("behavior", this); this.readyScript = getCompressor().compress( PropertyVariableInterpolator.interpolate(tt.getString(), map) ); } }