com.vaadin.ui.declarative.Design Maven / Gradle / Ivy
/*
* Vaadin Framework 7
*
* Copyright (C) 2000-2024 Vaadin Ltd
*
* This program is available under Vaadin Commercial License and Service Terms.
*
* See for the full
* license.
*/
package com.vaadin.ui.declarative;
import java.beans.IntrospectionException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.lang.annotation.Annotation;
import java.util.Collection;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Document.OutputSettings.Syntax;
import org.jsoup.nodes.DocumentType;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.parser.Parser;
import org.jsoup.select.Elements;
import com.vaadin.annotations.DesignRoot;
import com.vaadin.shared.util.SharedUtil;
import com.vaadin.ui.Component;
import com.vaadin.ui.declarative.DesignContext.ComponentCreatedEvent;
import com.vaadin.ui.declarative.DesignContext.ComponentCreationListener;
/**
* Design is used for reading a component hierarchy from an html string or input
* stream and, conversely, for writing an html representation corresponding to a
* given component hierarchy.
*
*
* In html form a valid nonempty component hierarchy contains a single root
* element located under the <body> tag. A hierarchy of components is
* achieved by nesting other elements under the root element. An empty component
* hierarchy is represented as no elements under the <body> tag.
*
*
* For writing a component hierarchy the root element is specified as a
* Component parameter or as a DesignContext object containing the root
* Component. An empty hierarchy can be written by giving a null root Component.
*
* @since 7.4
* @author Vaadin Ltd
*/
public class Design implements Serializable {
private static final String UTF8 = "UTF-8";
/**
* Callback for creating instances of a given component class when reading
* designs. The default implementation, {@link DefaultComponentFactory} will
* use Class.forName(className).newInstance()
, which might not
* be suitable e.g. in an OSGi environment or if the Component instances
* should be created as managed CDI beans.
*
* Use {@link Design#setComponentFactory(ComponentFactory)} to configure
* Vaadin to use a custom component factory.
*
* @since 7.4.1
*/
public interface ComponentFactory extends Serializable {
/**
* Creates a component based on the fully qualified name derived from
* the tag name in the design.
*
* @param fullyQualifiedClassName
* the fully qualified name of the component to create
* @param context
* the design context for which the component is created
*
* @return a newly created component
*/
public Component createComponent(String fullyQualifiedClassName,
DesignContext context);
}
/**
* Delegate for handling the mapping between tag names and component
* instances.
*
* Use {@link Design#setComponentMapper(ComponentMapper)} to configure
* Vaadin to use a custom component mapper.
*
* @since 7.5.0
* @author Vaadin Ltd
*/
public interface ComponentMapper extends Serializable {
/**
* Resolves and creates a component using the provided component factory
* based on a tag name.
*
* This method should be in sync with
* {@link #componentToTag(Component, DesignContext)} so that the
* resolved tag for a created component is the same as the tag for which
* the component was created.
*
* @param tag
* the tag name to create a component for
* @param componentFactory
* the component factory that actually creates a component
* based on a fully qualified class name
* @param context
* the design context for which the component is created
* @return a newly created component
*/
public Component tagToComponent(String tag,
ComponentFactory componentFactory, DesignContext context);
/**
* Resolves a tag name from a component.
*
* @param component
* the component to get a tag name for
* @param context
* the design context for which the tag name is needed
* @return the tag name corresponding to the component
*/
public String componentToTag(Component component,
DesignContext context);
}
/**
* Default implementation of {@link ComponentFactory}, using
* Class.forName(className).newInstance()
for finding the
* component class and creating a component instance.
*
* @since 7.4.1
*/
public static class DefaultComponentFactory implements ComponentFactory {
@Override
public Component createComponent(String fullyQualifiedClassName,
DesignContext context) {
Class extends Component> componentClass;
try {
componentClass = resolveComponentClass(fullyQualifiedClassName,
context);
} catch (DesignException e) {
// Try with an inner class.
int lastDot = fullyQualifiedClassName.lastIndexOf('.');
if (lastDot != -1) {
String qualifiedInnerClassName = fullyQualifiedClassName
.substring(0, lastDot) + "$"
+ fullyQualifiedClassName.substring(lastDot + 1);
return createComponent(qualifiedInnerClassName, context);
} else {
throw e;
}
}
assert Component.class.isAssignableFrom(
componentClass) : "resolveComponentClass returned "
+ componentClass
+ " which is not a Vaadin Component class";
try {
return componentClass.newInstance();
} catch (Exception e) {
throw new DesignException(
"Could not create component " + fullyQualifiedClassName,
e);
}
}
/**
* Resolves a component class based on the fully qualified name of the
* class.
*
* @param qualifiedClassName
* the fully qualified name of the resolved class
* @param context
* the design context for which the class is resolved
* @return a component class object representing the provided class name
*/
protected Class extends Component> resolveComponentClass(
String qualifiedClassName, DesignContext context) {
try {
Class> componentClass = Class.forName(qualifiedClassName);
return componentClass.asSubclass(Component.class);
} catch (ClassNotFoundException e) {
throw new DesignException("Unable to load component for design",
e);
}
}
}
/**
* Default implementation of {@link ComponentMapper},
*
* @since 7.5.0
*/
public static class DefaultComponentMapper implements ComponentMapper {
@Override
public Component tagToComponent(String tagName,
ComponentFactory componentFactory, DesignContext context) {
// Extract the package and class names.
// Otherwise, get the full class name using the prefix to package
// mapping. Example: "vaadin-vertical-layout" ->
// "com.vaadin.ui.VerticalLayout"
String[] parts = tagName.split("-", 2);
if (parts.length < 2) {
throw new DesignException("The tagname '" + tagName
+ "' is invalid: missing prefix.");
}
String prefixName = parts[0];
String packageName = context.getPackage(prefixName);
if (packageName == null) {
throw new DesignException("Unknown tag: " + tagName);
}
String[] classNameParts = parts[1].split("-");
String className = "";
for (String classNamePart : classNameParts) {
// Split will ignore trailing and multiple dashes but that
// should be
// ok
// will be resolved to
// will be resolved to
className += SharedUtil.capitalize(classNamePart);
}
String qualifiedClassName = packageName + "." + className;
Component component = componentFactory
.createComponent(qualifiedClassName, context);
if (component == null) {
throw new DesignException("Got unexpected null component from "
+ componentFactory.getClass().getName() + " for class "
+ qualifiedClassName);
}
return component;
}
@Override
public String componentToTag(Component component,
DesignContext context) {
Class> componentClass = component.getClass();
String packageName = getPackageName(componentClass);
String prefix = context.getPackagePrefix(packageName);
if (prefix == null) {
prefix = packageName.replace('.', '_')
.toLowerCase(Locale.ENGLISH);
context.addPackagePrefix(prefix, packageName);
}
prefix = prefix + "-";
String className = classNameToElementName(
componentClass.getSimpleName());
String tagName = prefix + className;
return tagName;
}
private String getPackageName(Class> componentClass) {
if (componentClass.isMemberClass()) {
Class> enclosingClass = componentClass.getEnclosingClass();
return getPackageName(enclosingClass) + "."
+ enclosingClass.getSimpleName();
} else {
return componentClass.getPackage().getName();
}
}
/**
* Creates the name of the html tag corresponding to the given class
* name. The name is derived by converting each uppercase letter to
* lowercase and inserting a dash before the letter. No dash is inserted
* before the first letter of the class name.
*
* @param className
* the name of the class without a package name
* @return the html tag name corresponding to className
*/
private String classNameToElementName(String className) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < className.length(); i++) {
Character c = className.charAt(i);
if (Character.isUpperCase(c)) {
if (i > 0) {
result.append("-");
}
result.append(Character.toLowerCase(c));
} else {
result.append(c);
}
}
return result.toString();
}
}
private static volatile ComponentFactory componentFactory = new DefaultComponentFactory();
private static volatile ComponentMapper componentMapper = new DefaultComponentMapper();
/**
* Sets the component factory that is used for creating component instances
* based on fully qualified class names derived from a design file.
*
* Please note that this setting is global, so care should be taken to avoid
* conflicting changes.
*
* @param componentFactory
* the component factory to set; not null
*
* @since 7.4.1
*/
public static void setComponentFactory(ComponentFactory componentFactory) {
if (componentFactory == null) {
throw new IllegalArgumentException(
"Cannot set null component factory");
}
Design.componentFactory = componentFactory;
}
/**
* Gets the currently used component factory.
*
* @see #setComponentFactory(ComponentFactory)
*
* @return the component factory
*
* @since 7.4.1
*/
public static ComponentFactory getComponentFactory() {
return componentFactory;
}
/**
* Sets the component mapper that is used for resolving between tag names
* and component instances.
*
* Please note that this setting is global, so care should be taken to avoid
* conflicting changes.
*
* @param componentMapper
* the component mapper to set; not null
*
* @since 7.5.0
*/
public static void setComponentMapper(ComponentMapper componentMapper) {
if (componentMapper == null) {
throw new IllegalArgumentException(
"Cannot set null component mapper");
}
Design.componentMapper = componentMapper;
}
/**
* Gets the currently used component mapper.
*
* @see #setComponentMapper(ComponentMapper)
*
* @return the component mapper
*
* @since 7.5.0
*/
public static ComponentMapper getComponentMapper() {
return componentMapper;
}
/**
* Parses the given input stream into a jsoup document
*
* @param html
* the stream containing the design
* @return the parsed jsoup document
* @throws IOException
*/
private static Document parse(InputStream html) {
try {
Document doc = Jsoup.parse(html, UTF8, "", Parser.htmlParser());
return doc;
} catch (IOException e) {
throw new DesignException("The html document cannot be parsed.");
}
}
/**
* Constructs a component hierarchy from the design specified as an html
* tree.
*
*
* If a component root is given, the component instances created during
* reading the design are assigned to its member fields based on their id,
* local id, and caption
*
* @param doc
* the html tree
* @param componentRoot
* optional component root instance. The type must match the type
* of the root element in the design. Any member fields whose
* type is assignable from {@link Component} are bound to fields
* in the design based on id/local id/caption
*/
private static DesignContext designToComponentTree(Document doc,
Component componentRoot) {
if (componentRoot == null) {
return designToComponentTree(doc, null, null);
} else {
return designToComponentTree(doc, componentRoot,
componentRoot.getClass());
}
}
/**
* Constructs a component hierarchy from the design specified as an html
* tree.
*
*
* If a component root is given, the component instances created during
* reading the design are assigned to its member fields based on their id,
* local id, and caption
*
* @param doc
* the html tree
* @param componentRoot
* optional component root instance. The type must match the type
* of the root element in the design.
* @param classWithFields
* a class (componentRoot class or a super class) with some
* member fields. The member fields whose type is assignable from
* {@link Component} are bound to fields in the design based on
* id/local id/caption
*/
private static DesignContext designToComponentTree(Document doc,
Component componentRoot, Class> classWithFields) {
DesignContext designContext = new DesignContext(doc);
designContext.readPackageMappings(doc);
// No special handling for a document without a body element - should be
// taken care of by jsoup.
Element root = doc.body();
Elements children = root.children();
if (children.size() > 1) {
throw new DesignException(
"The first level of a component hierarchy should contain at most one root component, but found "
+ children.size() + ".");
}
Element element = children.size() == 0 ? null : children.first();
if (componentRoot != null) {
if (element == null) {
throw new DesignException(
"The root element cannot be null when the specified root Component is"
+ " not null.");
}
// user has specified root instance that may have member fields that
// should be bound
final FieldBinder binder;
try {
binder = new FieldBinder(componentRoot, classWithFields);
} catch (IntrospectionException e) {
throw new DesignException(
"Could not bind fields of the root component", e);
}
// create listener for component creations that binds the created
// components to the componentRoot instance fields
ComponentCreationListener creationListener = new ComponentCreationListener() {
@Override
public void componentCreated(ComponentCreatedEvent event) {
binder.bindField(event.getComponent(), event.getLocalId());
}
};
designContext.addComponentCreationListener(creationListener);
// create subtree
designContext.readDesign(element, componentRoot);
// make sure that all the member fields are bound
Collection unboundFields = binder.getUnboundFields();
if (!unboundFields.isEmpty()) {
throw new DesignException(
"Found unbound fields from component root "
+ unboundFields);
}
// no need to listen anymore
designContext.removeComponentCreationListener(creationListener);
} else {
// createChild creates the entire component hierarchy
componentRoot = element == null ? null
: designContext.readDesign(element);
}
designContext.setRootComponent(componentRoot);
return designContext;
}
/**
* Generates an html tree representation of the component hierarchy having
* the root designContext.getRootComponent(). The hierarchy is stored under
* <body> in the tree. The generated tree represents a valid html
* document.
*
*
* @param designContext
* a DesignContext object specifying the root component
* (designContext.getRootComponent()) of the hierarchy
* @return an html tree representation of the component hierarchy
*/
private static Document createHtml(DesignContext designContext) {
// Create the html tree skeleton.
Document doc = new Document("");
DocumentType docType = new DocumentType("html", "", "");
doc.appendChild(docType);
Element html = doc.createElement("html");
doc.appendChild(html);
html.appendChild(doc.createElement("head"));
Element body = doc.createElement("body");
html.appendChild(body);
// Append the design under in the html tree. createNode
// creates the entire component hierarchy rooted at the
// given root node.
Component root = designContext.getRootComponent();
if (root != null) {
Node rootNode = designContext.createElement(root);
body.appendChild(rootNode);
}
designContext.writePackageMappings(doc);
return doc;
}
/**
* Loads a design for the given root component.
*
* This methods assumes that the component class (or a super class) has been
* marked with an {@link DesignRoot} annotation and will either use the
* value from the annotation to locate the design file, or will fall back to
* using a design with the same same as the annotated class file (with an
* .html extension)
*
* Any {@link Component} type fields in the root component which are not
* assigned (i.e. are null) are mapped to corresponding components in the
* design. Matching is done based on field name in the component class and
* id/local id/caption in the design file.
*
* The type of the root component must match the root element in the design
*
* @param rootComponent
* The root component of the layout
* @return The design context used in the load operation
* @throws DesignException
* If the design could not be loaded
*/
public static DesignContext read(Component rootComponent)
throws DesignException {
// Try to find an @DesignRoot annotation on the class or any parent
// class
Class extends Component> annotatedClass = findClassWithAnnotation(
rootComponent.getClass(), DesignRoot.class);
if (annotatedClass == null) {
throw new IllegalArgumentException("The class "
+ rootComponent.getClass().getName()
+ " or any of its superclasses do not have an @DesignRoot annotation");
}
DesignRoot designAnnotation = annotatedClass
.getAnnotation(DesignRoot.class);
String filename = designAnnotation.value();
if (filename.equals("")) {
// No value, assume the html file is named as the class
filename = annotatedClass.getSimpleName() + ".html";
}
InputStream stream = annotatedClass.getResourceAsStream(filename);
if (stream == null) {
throw new DesignException("Unable to find design file " + filename
+ " in " + annotatedClass.getPackage().getName());
}
try {
Document doc = parse(stream);
DesignContext context = designToComponentTree(doc, rootComponent,
annotatedClass);
return context;
} finally {
try {
stream.close();
} catch (IOException e) {
getLogger().log(Level.FINE, "Error closing design stream", e);
}
}
}
private static Logger getLogger() {
return Logger.getLogger(Design.class.getName());
}
/**
* Find the first class with the given annotation, starting the search from
* the given class and moving upwards in the class hierarchy.
*
* @param componentClass
* the class to check
* @param annotationClass
* the annotation to look for
* @return the first class with the given annotation or null if no class
* with the annotation was found
*/
private static Class extends Component> findClassWithAnnotation(
Class extends Component> componentClass,
Class extends Annotation> annotationClass) {
if (componentClass == null) {
return null;
}
if (componentClass.isAnnotationPresent(annotationClass)) {
return componentClass;
}
Class> superClass = componentClass.getSuperclass();
if (!Component.class.isAssignableFrom(superClass)) {
return null;
}
return findClassWithAnnotation(superClass.asSubclass(Component.class),
annotationClass);
}
/**
* Loads a design from the given file name using the given root component.
*
* Any {@link Component} type fields in the root component which are not
* assigned (i.e. are null) are mapped to corresponding components in the
* design. Matching is done based on field name in the component class and
* id/local id/caption in the design file.
*
* The type of the root component must match the root element in the design.
*
* @param filename
* The file name to load. Loaded from the same package as the
* root component
* @param rootComponent
* The root component of the layout
* @return The design context used in the load operation
* @throws DesignException
* If the design could not be loaded
*/
public static DesignContext read(String filename, Component rootComponent)
throws DesignException {
InputStream stream = rootComponent.getClass()
.getResourceAsStream(filename);
if (stream == null) {
throw new DesignException(
"File " + filename + " was not found in the package "
+ rootComponent.getClass().getPackage().getName());
}
try {
return read(stream, rootComponent);
} finally {
try {
stream.close();
} catch (IOException e) {
getLogger().log(Level.FINE, "Error closing design stream", e);
}
}
}
/**
* Loads a design from the given stream using the given root component. If
* rootComponent is null, the type of the root node is read from the design.
*
* Any {@link Component} type fields in the root component which are not
* assigned (i.e. are null) are mapped to corresponding components in the
* design. Matching is done based on field name in the component class and
* id/local id/caption in the design file.
*
* If rootComponent is not null, its type must match the type of the root
* element in the design
*
* @param stream
* The stream to read the design from
* @param rootComponent
* The root component of the layout
* @return The design context used in the load operation
* @throws DesignException
* If the design could not be loaded
*/
public static DesignContext read(InputStream stream,
Component rootComponent) {
if (stream == null) {
throw new DesignException("Stream cannot be null");
}
Document doc = parse(stream);
DesignContext context = designToComponentTree(doc, rootComponent);
return context;
}
/**
* Loads a design from the given input stream
*
* @param design
* The stream to read the design from
* @return The root component of the design
*/
public static Component read(InputStream design) {
DesignContext context = read(design, null);
return context.getRootComponent();
}
/**
* Writes the given component tree in design format to the given output
* stream.
*
* @param component
* the root component of the component tree, null can be used for
* generating an empty design
* @param outputStream
* the output stream to write the design to. The design is always
* written as UTF-8
* @throws IOException
* if writing fails
*/
public static void write(Component component, OutputStream outputStream)
throws IOException {
DesignContext dc = new DesignContext();
dc.setRootComponent(component);
write(dc, outputStream);
}
/**
* Writes the component, given in the design context, in design format to
* the given output stream. The design context is used for writing local ids
* and other information not available in the component tree.
*
* @param designContext
* The DesignContext object specifying the component hierarchy
* and the local id values of the objects. If
* designContext.getRootComponent() is null, an empty design will
* be generated.
* @param outputStream
* the output stream to write the design to. The design is always
* written as UTF-8
* @throws IOException
* if writing fails
*/
public static void write(DesignContext designContext,
OutputStream outputStream) throws IOException {
Document doc = createHtml(designContext);
write(doc, outputStream);
}
/**
* Writes the given jsoup document to the output stream (in UTF-8)
*
* @param doc
* the document to write
* @param outputStream
* the stream to write to
* @throws IOException
* if writing fails
*/
private static void write(Document doc, OutputStream outputStream)
throws IOException {
doc.outputSettings().indentAmount(4);
doc.outputSettings().syntax(Syntax.html);
doc.outputSettings().prettyPrint(true);
outputStream.write(doc.html().getBytes(UTF8));
}
}