/* * Copyright (C) 2012 Red Hat, Inc. and/or its affiliates. * * 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 org.jboss.errai.ui.shared; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Queue; import java.util.function.Supplier; import java.util.logging.Logger; import java.util.stream.Stream; import org.jboss.errai.common.client.dom.DOMUtil; import org.jboss.errai.common.client.dom.HTMLElement; import org.jboss.errai.common.client.ui.ElementWrapperWidget; import org.jboss.errai.common.client.util.CreationalCallback; import org.jboss.errai.ioc.client.container.IOC; import org.jboss.errai.ioc.client.container.IOCResolutionException; import org.jboss.errai.ui.client.local.spi.TemplateProvider; import org.jboss.errai.ui.client.local.spi.TemplateRenderingCallback; import org.jboss.errai.ui.client.local.spi.TranslationService; import org.jboss.errai.ui.client.widget.ListWidget; import org.jboss.errai.ui.shared.api.annotations.DataField.ConflictStrategy; import org.jboss.errai.ui.shared.api.annotations.Templated; import org.jboss.errai.ui.shared.api.style.StyleBindingsRegistry; import org.jboss.errai.ui.shared.wrapper.ElementWrapper; import com.google.gwt.core.client.JavaScriptObject; import com.google.gwt.core.client.JsArray; import com.google.gwt.core.shared.GWT; import com.google.gwt.dom.client.Element; import com.google.gwt.dom.client.Node; import com.google.gwt.event.shared.EventHandler; import com.google.gwt.user.client.DOM; import com.google.gwt.user.client.EventListener; import com.google.gwt.user.client.ui.Composite; import com.google.gwt.user.client.ui.HasText; import com.google.gwt.user.client.ui.RootPanel; import com.google.gwt.user.client.ui.UIObject; import com.google.gwt.user.client.ui.Widget; import jsinterop.annotations.JsType; /** * Errai UI Runtime Utility for handling {@link Template} composition. * * @author <a href="mailto:lincolnbaxter@gmail.com">Lincoln Baxter, III</a> * @author Max Barkley <mbarkley@redhat.com> * @author Christian Sadilek <csadilek@redhat.com> */ public final class TemplateUtil { private static final Logger logger = Logger.getLogger(TemplateUtil.class.getName()); private static final Map<Object, List<Runnable>> cleanupTasks = new IdentityHashMap<>(); private static TranslationService translationService = null; public static TranslationService getTranslationService() { if (translationService == null) { translationService = GWT.create(TranslationService.class); } return translationService; } private TemplateUtil() { } public static void compositeComponentReplace(final String componentType, final String templateFile, final Supplier<Widget> field, final Map<String, Element> dataFieldElements, final Map<String, DataFieldMeta> dataFieldMetas, final String fieldName) { try { compositeComponentReplace(componentType, templateFile, field.get(), dataFieldElements, dataFieldMetas, fieldName); } catch (final Throwable t) { throw new RuntimeException("There was an error initializing the @DataField " + fieldName + " in the @Templated " + componentType + ": " + t.getMessage(), t); } } /** * Replace the {@link Element} with the data-field of the given * {@link String} with the root {@link Element} of the given {@link UIObject} */ public static void compositeComponentReplace(final String componentType, final String templateFile, final Widget field, final Map<String, Element> dataFieldElements, final Map<String, DataFieldMeta> dataFieldMetas, final String fieldName) { if (field == null) { throw new IllegalStateException("Widget to be composited into [" + componentType + "] field [" + fieldName + "] was null. Did you forget to @Inject or initialize this @DataField?"); } final Element element = dataFieldElements.get(fieldName); final DataFieldMeta meta = dataFieldMetas.get(fieldName); if (element == null) { throw new IllegalStateException("Template [" + templateFile + "] did not contain data-field, id or class attribute for field [" + componentType + "." + fieldName + "]"); } logger.finer("Compositing @Replace [data-field=" + fieldName + "] element [" + element + "] with Component " + field.getClass().getName() + " [" + field.getElement() + "]"); if (!element.getTagName().equals(field.getElement().getTagName())) { logger.warning("Replacing Element type [" + element.getTagName() + "] in " + templateFile + " with type [" + field.getElement().getTagName() + "] for " + fieldName + " in " + componentType); } final Element parentElement = element.getParentElement(); try { if (field instanceof HasText && (!(field instanceof ElementWrapperWidget) || field.getElement().getChildCount() == 0)) { Node firstNode = element.getFirstChild(); while (firstNode != null) { if (firstNode != element.getFirstChildElement()) field.getElement().appendChild(element.getFirstChild()); else { field.getElement().appendChild(element.getFirstChildElement()); } firstNode = element.getFirstChild(); } } parentElement.replaceChild(field.getElement(), element); final boolean hasI18nKey = !field.getElement().getAttribute("data-i18n-key").equals(""); final boolean hasI18nPrefix = !field.getElement().getAttribute("data-i18n-prefix").equals(""); /* * Preserve template Element attributes. */ final JsArray<Node> templateAttributes = getAttributes(element); for (int i = 0; i < templateAttributes.length(); i++) { final Node node = templateAttributes.get(i); final String name = node.getNodeName(); final String value = node.getNodeValue(); /* * If this new component is templated, do not overwrite i18n related attributes. */ if ((name.equals("data-i18n-key") || name.equals("data-role") && value.equals("dummy")) && (hasI18nKey || hasI18nPrefix)) continue; mergeAttribute(meta, field.getElement().cast(), element.cast(), name, value); final Element previous = dataFieldElements.put(fieldName, field.getElement()); final Element root = dataFieldElements.get("this"); if (root != null && root == previous) { dataFieldElements.put("this", field.getElement()); } } } catch (final Exception e) { throw new IllegalStateException("Could not replace Element with [data-field=" + fieldName + "]" + " - Did you already @Insert or @Replace a parent Element?" + " Is an element referenced by more than one @DataField?", e); } } private static void mergeAttribute(final DataFieldMeta meta, final HTMLElement beanElement, final HTMLElement templateElement, final String name, final String value) { final ConflictStrategy strategy = meta.getStrategy(name); // Merge all class names regardless of strategy if (name.equals("class")) { DOMUtil.tokenStream(templateElement.getClassList()) .filter(token -> !beanElement.getClassList().contains(token)) .forEach(token -> beanElement.getClassList().add(token)); } // Merge individual properties in style only using the strategy when both elements have a value. else if (name.equals("style")) { Stream<String> propertyNameStream = DOMUtil.cssPropertyNameStream(templateElement.getStyle()); if (ConflictStrategy.USE_BEAN.equals(strategy)) { propertyNameStream = propertyNameStream .filter(propertyName -> { final String beanPropertyValue = beanElement.getStyle().getPropertyValue(propertyName); return beanPropertyValue == null || beanPropertyValue.isEmpty(); }); } propertyNameStream .forEach(propertyName -> beanElement.getStyle().setProperty(propertyName, templateElement.getStyle().getPropertyValue(propertyName), "")); } // Use strategy to decide which value is used. else { final String beanValue = beanElement.getAttribute(name); if (ConflictStrategy.USE_TEMPLATE.equals(strategy) || beanValue == null || beanValue.isEmpty()) { beanElement.setAttribute(name, value); } } } public static Element asElement(final Object element) { try { return nativeCast(element); } catch (final Throwable t) { throw new RuntimeException("Error casting @DataField of type " + element.getClass().getName() + " to " + Element.class.getName(), t); } } public static HTMLElement asErraiElement(final Object element) { try { return nativeCast(element); } catch (final Throwable t) { throw new RuntimeException("Error casting @DataField of type " + element.getClass().getName() + " to org.jboss.errai.common.client.dom.HTMLElement", t); } } /** * Only works for native {@link JsType JsTypes} and {@link JavaScriptObject JavaScriptObjects}. */ public static native <T> T nativeCast(Object element) /*-{ return element; }-*/; public static com.google.gwt.user.client.Element asDeprecatedElement(final Object element) { try { return nativeCast(element); } catch (final Throwable t) { throw new RuntimeException("Error casting @DataField of type " + element.getClass().getName() + " to " + com.google.gwt.user.client.Element.class.getName(), t); } } public static void initTemplated(final Object templated, final Element wrapped, final Collection<Widget> dataFields) { final TemplateWidget widget = new TemplateWidget(wrapped, dataFields); TemplateWidgetMapper.put(templated, widget); StyleBindingsRegistry.get().updateStyles(templated); widget.onAttach(); RootPanel.detachOnWindowClose(widget); TemplateInitializedEvent.fire(widget); } public static void cleanupTemplated(final Object templated) { final TemplateWidget templateWidget = TemplateWidgetMapper.get(templated); TemplateWidgetMapper.remove(templated); runCleanupTasks(templated); if (RootPanel.isInDetachList(templateWidget)) { RootPanel.detachNow(templateWidget); } } public static void initWidget(final Composite component, final Element wrapped, final Collection<Widget> dataFields) { if (!(component instanceof ListWidget)) { initWidgetNative(component, new TemplateWidget(wrapped, dataFields)); } if (!component.isAttached()) { onAttachNative(component); RootPanel.detachOnWindowClose(component); } StyleBindingsRegistry.get().updateStyles(component); TemplateInitializedEvent.fire(component); } public static void cleanupWidget(final Composite component) { runCleanupTasks(component); if (RootPanel.isInDetachList(component)) { RootPanel.detachNow(component); } } public static void runCleanupTasks(final Object component) { Optional .ofNullable(cleanupTasks.remove(component)) .ifPresent(tasks -> tasks.forEach(Runnable::run)); } private static native void onAttachNative(Widget w) /*-{ w.@com.google.gwt.user.client.ui.Widget::onAttach()(); }-*/; private static native void initWidgetNative(Composite component, Widget wrapped) /*-{ component.@com.google.gwt.user.client.ui.Composite::initWidget(Lcom/google/gwt/user/client/ui/Widget;)(wrapped); }-*/; private static Map<String, Element> templateRoots = new HashMap<>(); public static Element getRootTemplateParentElement(final String templateContents, final String templateFileName, final String rootField) { final String key = templateFileName + "#" + rootField; if (templateRoots.containsKey(key)) { return cloneIntoNewParent(templateRoots.get(key)); } Element parserDiv = DOM.createDiv(); parserDiv.setInnerHTML(templateContents); if (rootField != null && !rootField.trim().isEmpty()) { logger.finer("Locating root element: " + rootField); final VisitContext<TaggedElement> context = Visit.depthFirst(parserDiv, new Visitor<TaggedElement>() { @Override public boolean visit(final VisitContextMutable<TaggedElement> context, final Element element) { for (final AttributeType attrType : AttributeType.values()) { final String attrName = attrType.getAttributeName(); final TaggedElement existingCandidate = context.getResult(); if (element.hasAttribute(attrName) && element.getAttribute(attrName).equals(rootField) && (existingCandidate == null || existingCandidate.getAttributeType().ordinal() < attrType.ordinal())) { context.setResult(new TaggedElement(attrType, element)); } } return true; } }); if (context.getResult() != null) { parserDiv = DOM.createDiv(); parserDiv.appendChild(context.getResult().getElement()); } else { throw new IllegalStateException("Could not locate Element in template with data-field, id or class = [" + rootField + "]\n" + parserDiv.getInnerHTML()); } } logger.finest(parserDiv.getInnerHTML().trim()); final Element templateRoot = firstNonMetaElement(parserDiv); if (templateRoot == null) { throw new IllegalStateException("Could not find template root for this template: " + templateContents); } else { templateRoots.put(key, templateRoot); return cloneIntoNewParent(templateRoot); } } public static Element getRootTemplateElement(final Element rootParent) { return firstNonMetaElement(rootParent); } /* * This ignores meta tags from ERRAI-779. */ private static Element firstNonMetaElement(final Element parserDiv) { Element displayable = parserDiv.getFirstChildElement(); while (displayable != null && displayable.getTagName().equalsIgnoreCase("meta")) { displayable = displayable.getNextSiblingElement(); } return displayable; } /** * Indicates the type of attribute a data field was discovered from. */ private enum AttributeType { CLASS("class"), ID("id"), DATA_FIELD("data-field"); private final String attributeName; AttributeType(final String attributeName) { this.attributeName = attributeName; } public String getAttributeName() { return attributeName; } } private static class TaggedElement { private final AttributeType attributeType; private final Element element; public TaggedElement(final AttributeType attributeType, final Element element) { this.attributeType = attributeType; this.element = element; } public AttributeType getAttributeType() { return attributeType; } public Element getElement() { return element; } } /** * Called to perform i18n translation on the given template. Add i18n-prefix attribute to root of * template to allow translation after bean creation. * * @param templateRoot */ public static void translateTemplate(final String templateFile, final Element templateRoot) { if (!getTranslationService().isEnabled()) return; logger.finer("Translating template: " + templateFile); final String i18nKeyPrefix = getI18nPrefix(templateFile); // Add i18n prefix attribute for post-creation translation templateRoot.setAttribute("data-i18n-prefix", i18nKeyPrefix); DomVisit.visit(new ElementWrapper(templateRoot), new TemplateTranslationVisitor(i18nKeyPrefix)); } /** * Generate an i18n key prefix from the given template filename. * * @param templateFile */ public static String getI18nPrefix(final String templateFile) { final int idx1 = templateFile.lastIndexOf('/'); final int idx2 = templateFile.lastIndexOf('.'); return templateFile.substring(idx1 + 1, idx2 + 1); } public static Map<String, Element> getDataFieldElements(final Element templateRoot) { final Map<String, Element> dataFields = new LinkedHashMap<>(); final Map<String, TaggedElement> childTemplateElements = new LinkedHashMap<>(); logger.finer("Searching template for fields."); // TODO do this as browser split deferred binding using // Document.querySelectorAll() - // https://developer.mozilla.org/En/DOM/Element.querySelectorAll Visit.depthFirst(templateRoot, new Visitor<Object>() { @Override public boolean visit(final VisitContextMutable<Object> context, final Element element) { for (final AttributeType attrType : AttributeType.values()) { final String attrName = attrType.getAttributeName(); final String attrVal = element.getAttribute(attrName); if (attrVal != null && !attrVal.isEmpty()) { final String[] attributeValues = (attrType == AttributeType.CLASS) ? attrVal.split(" +") : new String[]{attrVal}; for (final String dataFieldName : attributeValues) { final TaggedElement existingCandidate = childTemplateElements.get(dataFieldName); if (existingCandidate == null || existingCandidate.getAttributeType().ordinal() < attrType.ordinal()) { childTemplateElements.put(dataFieldName, new TaggedElement(attrType, element)); dataFields.put(dataFieldName, element); } } } } return true; } }); dataFields.put("this", templateRoot); return dataFields; } public static void setupNativeEventListener(final Object component, final ElementWrapperWidget wrapper, final EventListener listener, final int eventsToSink) { if (wrapper == null) { throw new RuntimeException("A native event source was specified in " + component.getClass().getName() + " but the corresponding data-field does not exist!"); } wrapper.setEventListener(eventsToSink, listener); } /** * Use this for elements that are not wrapped by any widgets (including the ElementWrapperWidget). */ public static void setupNativeEventListener(final Object component, final Element element, final EventListener listener, final int eventsToSink) { if (element == null) { throw new RuntimeException("A native event source was specified in " + component.getClass().getName() + " but the corresponding data-field does not exist!"); } DOM.setEventListener(element, listener); DOM.sinkEvents(element, eventsToSink); } public static void setupBrowserEventListener(final Object component, final HTMLElement element, final org.jboss.errai.common.client.dom.EventListener<?> listener, final String browserEventType) { if (element == null) { throw new RuntimeException("A browser event source was specified in " + component.getClass().getName() + " but the corresponding data-field does not exist!"); } element.addEventListener(browserEventType, listener, false); cleanupTasks .computeIfAbsent(component, key -> new ArrayList<>()) .add(() -> element.removeEventListener(browserEventType, listener, false)); } public static void setupBrowserEventListener(final Object component, final Object element, final org.jboss.errai.common.client.dom.EventListener<?> listener, final String browserEventType) { setupBrowserEventListener(component, TemplateUtil.asErraiElement(element), listener, browserEventType); } public static void setupBrowserEventListener(final Object component, final Widget widget, final org.jboss.errai.common.client.dom.EventListener<?> listener, final String browserEventType) { setupBrowserEventListener(component, widget.getElement(), listener, browserEventType); } public static <T extends EventHandler> Widget setupPlainElementEventHandler(final Composite component, final Element element, final T handler, final com.google.gwt.event.dom.client.DomEvent.Type<T> type) { final ElementWrapperWidget widget = ElementWrapperWidget.getWidget(element); widget.addDomHandler(handler, type); // TODO add to Composite as child. return widget; } public static <T extends EventHandler> void setupWrappedElementEventHandler(final Widget widget, final T handler, final com.google.gwt.event.dom.client.DomEvent.Type<T> type) { widget.addDomHandler(handler, type); } /** * Join strings inserting separator between them. */ private static String join(final String[] strings, final String separator) { final StringBuffer result = new StringBuffer(); for (final String s : strings) { if (result.length() != 0) { result.append(separator); } result.append(s); } return result.toString(); } private static native JsArray<Node> getAttributes(Element elem) /*-{ return elem.attributes; }-*/; private static Element cloneIntoNewParent(final Element element) { final Element parent = DOM.createDiv(); final Element clone = DOM.clone(element, true); parent.appendChild(clone); return parent; } private final static class TemplateRequest { final Class<?> templateProvider; final String location; final TemplateRenderingCallback renderingCallback; TemplateRequest(final Class<?> templateProvider, final String location, final TemplateRenderingCallback renderingCallback) { this.templateProvider = templateProvider; this.location = location; this.renderingCallback = renderingCallback; } } private static Queue<TemplateRequest> requests = new LinkedList<>(); /** * Called by the generated IOC bootstrapper if a provider is specified on a * templated composite (see {@link Templated#provider()}). This method will * make sure that templates will be provided and rendered in invocation order * even if a given provider is asynchronous. * * @param templateProvider * the template provider to use for supplying the template, must not * be null. * @param location * the location of the template, must not be null. * @param renderingCallback * the callback to invoke when the template is available, must not be * null. */ public static void provideTemplate(final Class<?> templateProvider, final String location, final TemplateRenderingCallback renderingCallback) { final TemplateRequest request = new TemplateRequest(templateProvider, location, renderingCallback); requests.add(request); if (requests.size() == 1) { provideNextTemplate(); } } @SuppressWarnings({ "rawtypes", "unchecked" }) private static void provideNextTemplate() { if (requests.isEmpty()) return; try { final TemplateRequest request = requests.peek(); IOC.getAsyncBeanManager().lookupBean(request.templateProvider).getInstance(new CreationalCallback() { @Override public void callback(final Object bean) { final TemplateProvider provider = ((TemplateProvider) bean); try { provider.provideTemplate(request.location, new TemplateRenderingCallback() { @Override public void renderTemplate(final String template) { request.renderingCallback.renderTemplate(template); requests.remove(); provideNextTemplate(); } }); } catch (final RuntimeException t) { requests.remove(); throw t; } } }); } catch (final IOCResolutionException ioce) { requests.remove(); throw ioce; } } }