org.jboss.hal.ballroom.form.AbstractFormItem.java Source code

Java tutorial

Introduction

Here is the source code for org.jboss.hal.ballroom.form.AbstractFormItem.java

Source

/*
 * Copyright 2015-2016 Red Hat, Inc, and individual contributors.
 *
 * 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
 *
 * https://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.hal.ballroom.form;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import com.google.gwt.event.logical.shared.ValueChangeEvent;
import com.google.gwt.event.logical.shared.ValueChangeHandler;
import com.google.gwt.event.shared.EventBus;
import com.google.gwt.event.shared.GwtEvent;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.event.shared.SimpleEventBus;
import elemental2.dom.HTMLElement;
import org.jboss.hal.ballroom.Attachable;
import org.jboss.hal.ballroom.dialog.Dialog;
import org.jboss.hal.ballroom.form.Form.State;
import org.jboss.hal.ballroom.form.ResolveExpressionEvent.ResolveExpressionHandler;
import org.jboss.hal.ballroom.wizard.Wizard;
import org.jboss.hal.dmr.Deprecation;

import static java.util.Collections.singletonList;
import static org.jboss.hal.ballroom.form.Decoration.*;
import static org.jboss.hal.ballroom.form.FormItemValidation.ValidationRule.ALWAYS;

/**
 * Base class for all form item implementations. Contains central logic for handling (default) values, various flags,
 * validation, expressions and event handling. All UI and DOM related code can be found in {@linkplain Appearance
 * appearances}.
 * <p>
 * A form item carries three different values:
 * <ol>
 * <li>{@linkplain #getValue() value}: The value of this form item which has the type {@code T}</li>
 * <li>{@linkplain #getExpressionValue() expression value}: The expression value of this form item (if expressions are
 * {@linkplain #supportsExpressions() supported}). The expression value is <em>always</em> a string.</li>
 * <li>default value: The default value of this form item (if any) which has the type {@code T}</li>
 * </ol>
 * <p>
 * The value and the expression value are mutual exclusive. Only one of them is allowed to be non-null.
 *
 * @param <T> The type of the form item's value.
 */
public abstract class AbstractFormItem<T> implements FormItem<T> {

    private String name;
    private final String label;
    private final String hint;
    private T value;
    private T defaultValue;
    private String expressionValue;

    private boolean required;
    private boolean modified;
    private boolean undefined;
    private boolean restricted;
    private boolean enabled;
    private boolean expressionAllowed;
    private Deprecation deprecation;

    private Form form;
    private SuggestHandler suggestHandler;
    private final EventBus eventBus;
    private final Map<State, Appearance<T>> appearances;
    private final List<FormItemValidation<T>> validationHandlers;
    private final List<ResolveExpressionHandler> resolveExpressionHandlers;
    private final List<com.google.web.bindery.event.shared.HandlerRegistration> handlers;

    AbstractFormItem(String name, String label, String hint) {
        this.name = name;
        this.label = label;
        this.hint = hint;
        this.value = null;
        this.defaultValue = null;
        this.expressionValue = null;

        this.required = false;
        this.modified = false;
        this.undefined = true;
        this.restricted = false;
        this.enabled = true;
        this.expressionAllowed = true;
        this.deprecation = null;

        this.suggestHandler = null;
        this.eventBus = new SimpleEventBus();
        this.appearances = new HashMap<>();
        this.validationHandlers = new LinkedList<>();
        this.validationHandlers.addAll(defaultValidationHandlers());
        this.resolveExpressionHandlers = new LinkedList<>();
        this.handlers = new ArrayList<>();
    }

    protected void addAppearance(State state, Appearance<T> appearance) {
        appearances.put(state, appearance);
        appearance.setLabel(label);
        if (hint != null) {
            appearance.apply(HINT, hint);
        }
    }

    /** Store the event handler registration to remove them in {@link #detach()}. */
    protected void remember(com.google.web.bindery.event.shared.HandlerRegistration handler) {
        handlers.add(handler);
    }

    // ------------------------------------------------------ element and appearance

    @Override
    public HTMLElement element(State state) {
        if (appearances.containsKey(state)) {
            return appearances.get(state).element();
        } else {
            throw new IllegalStateException("Unknown state in FormItem.element(" + state + ")");
        }
    }

    /**
     * Calls {@code SuggestHandler.attach()} in case there was one registered. If you override this method, please
     * call {@code super.attach()} to keep this behaviour.
     */
    @Override
    public void attach() {
        if (form != null) {
            // if there's a back reference use it to attach only the appearances which are supported by the form
            for (Map.Entry<State, Appearance<T>> entry : appearances.entrySet()) {
                State state = entry.getKey();
                if (form.getStateMachine().supports(state)) {
                    Appearance<T> appearance = entry.getValue();
                    appearance.attach();
                }
            }
            if (form.getStateMachine().supports(State.EDITING) && suggestHandler instanceof Attachable) {
                ((Attachable) suggestHandler).attach();
            }

        } else {
            appearances.values().forEach(Appearance::attach);
            if (suggestHandler instanceof Attachable) {
                ((Attachable) suggestHandler).attach();
            }
        }
    }

    @Override
    public void detach() {
        if (suggestHandler instanceof Attachable) {
            ((Attachable) suggestHandler).detach();
        }
        appearances.values().forEach(Appearance::detach);
        for (com.google.web.bindery.event.shared.HandlerRegistration handler : handlers) {
            handler.removeHandler();
        }
        handlers.clear();
    }

    private void apply(Decoration decoration) {
        apply(decoration, null);
    }

    private <C> void apply(Decoration decoration, C context) {
        appearances.values().forEach(a -> a.apply(decoration, context));
    }

    private void unapply(Decoration decoration) {
        appearances.values().forEach(a -> a.unapply(decoration));
    }

    Appearance<T> appearance(State state) {
        if (appearances.containsKey(state)) {
            return appearances.get(state);
        }
        return null;
    }

    // ------------------------------------------------------ id, value & name

    @Override
    public String getId(State state) {
        Appearance<T> appearance = appearance(state);
        return appearance != null ? appearance.getId() : null;
    }

    @Override
    public void setId(String id) {
        appearances.values().forEach(a -> a.setId(id));
    }

    @Override
    public T getValue() {
        return value;
    }

    @Override
    public void setValue(T value) {
        setValue(value, false);
    }

    /**
     * Sets the form item's value and shows the value in the appearances. Sets the expression value to {@code null}.
     * Does not touch the {@code modified} and {@code undefined} flags. Should be called from business code like form
     * mapping.
     */
    @Override
    public void setValue(T value, boolean fireEvent) {
        this.value = value;
        this.expressionValue = null;

        appearances.values().forEach(a -> {
            a.showValue(value);
            if (isEmpty() && defaultValue != null) {
                a.apply(DEFAULT, a.asString(defaultValue));
            } else {
                a.unapply(DEFAULT);
            }
            if (supportsExpressions()) {
                a.unapply(EXPRESSION);
            }
        });

        if (fireEvent) {
            signalChange(value);
        }
    }

    /**
     * Assigns a new value to the internal value and adjusts the {@code modified} and {@code undefined} flags.
     * Should be called from change handlers. Does not update any appearances nor apply / unapply decorations.
     */
    protected void modifyValue(T newValue) {
        this.value = newValue;
        this.expressionValue = null;

        setModified(true);
        setUndefined(isEmpty());
        signalChange(newValue);
    }

    /**
     * Sets the value and expression value to {@code null}, {@linkplain #clearError() clears any error marker} and
     * shows the default value (if any). Does not touch the {@code modified} and {@code undefined} flags. Should be
     * called from business code like form mapping.
     */
    @Override
    public void clearValue() {
        this.value = null;
        this.expressionValue = null;

        appearances.values().forEach((a) -> {
            a.clearValue();
            a.unapply(INVALID);
            if (supportsExpressions()) {
                a.unapply(EXPRESSION);
            }
        });
        markDefaultValue(defaultValue != null);
    }

    /**
     * Stores the default value for later use. The default value will be used in {@link #setValue(Object)} (if the
     * value is null or empty) and {@link #clearValue()}. Calling this method will <strong>not</strong> immediately
     * show the default value.
     */
    @Override
    public void assignDefaultValue(T defaultValue) {
        this.defaultValue = defaultValue;
    }

    private void markDefaultValue(boolean on) {
        if (on) {
            appearances.values().forEach(a -> a.apply(DEFAULT, a.asString(defaultValue)));
        } else {
            unapply(DEFAULT);
        }
    }

    @Override
    public void mask() {
        apply(SENSITIVE);
    }

    @Override
    public void unmask() {
        unapply(SENSITIVE);
    }

    private void signalChange(T value) {
        ValueChangeEvent.fire(this, value);
    }

    @Override
    public void fireEvent(GwtEvent<?> gwtEvent) {
        eventBus.fireEvent(gwtEvent);
    }

    @Override
    public HandlerRegistration addValueChangeHandler(ValueChangeHandler<T> valueChangeHandler) {
        return eventBus.addHandler(ValueChangeEvent.getType(), valueChangeHandler);
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public void setName(String name) {
        this.name = name;
        appearances.values().forEach(a -> a.setName(name));
    }

    // ------------------------------------------------------ validation

    List<FormItemValidation<T>> defaultValidationHandlers() {
        return singletonList(new RequiredValidation<>(this));
    }

    @SuppressWarnings({ "SimplifiableIfStatement", "WeakerAccess" })
    boolean requiresValidation() {
        if (isRequired()) {
            return true;
        }

        if (!validationHandlers.isEmpty()) {
            // if there's a validation handler with ValidationRule == ALWAYS,
            // we need to validate for sure, otherwise we only need to validate
            // if the form item is modified
            if (validationHandlers.stream().anyMatch(vh -> vh.validateIf() == ALWAYS)) {
                return true;
            } else {
                // only validation handler with ValidationRule == IF_MODIFIED,
                // return true if the form item is defined && modified
                return !isUndefined() && isModified();
            }
        }

        // no validation handlers - no need to validate
        return false;
    }

    @Override
    public void addValidationHandler(FormItemValidation<T> validationHandler) {
        if (validationHandler != null) {
            validationHandlers.add(validationHandler);
        }
    }

    void removeValidationHandler(FormItemValidation<T> validationHandler) {
        if (validationHandler != null) {
            validationHandlers.remove(validationHandler);
        }
    }

    @Override
    public boolean validate() {
        if (requiresValidation()) {
            for (FormItemValidation<T> validationHandler : validationHandlers) {
                ValidationResult result = validationHandler.validate(value);
                if (!result.isValid()) {
                    showError(result.getMessage());
                    return false;
                }
            }
        }
        clearError();
        return true;
    }

    /**
     * Clears any error markers. This method {@linkplain Appearance#unapply(Decoration) unapplies} the {@linkplain
     * Decoration#INVALID INVALID} decoration.
     */
    @Override
    public void clearError() {
        unapply(INVALID);
    }

    /**
     * Shows the specified error message. This method {@linkplain Appearance#apply(Decoration, Object) applies} the
     * {@linkplain Decoration#INVALID INVALID} decoration using the error message as context.
     */
    @Override
    public void showError(String message) {
        apply(INVALID, message);
    }

    // ------------------------------------------------------ expressions

    @Override
    public boolean isExpressionAllowed() {
        return expressionAllowed;
    }

    @Override
    public void setExpressionAllowed(boolean expressionAllowed) {
        this.expressionAllowed = expressionAllowed;
    }

    @Override
    public boolean isExpressionValue() {
        return supportsExpressions() && hasExpressionScheme(expressionValue);
    }

    @Override
    public String getExpressionValue() {
        return expressionValue;
    }

    /**
     * Sets the form item's expression value, applies the {@link Decoration#EXPRESSION} decoration and shows the
     * expression value in the appearances. Sets the value to {@code null}. Does not touch the {@code modified} and
     * {@code undefined} flags. Should be called from business code like form mapping.
     */
    @Override
    public void setExpressionValue(String expressionValue) {
        this.value = null;
        this.expressionValue = expressionValue;

        appearances.values().forEach(a -> {
            a.unapply(DEFAULT);
            a.showExpression(expressionValue);
        });
        toggleExpressionSupport(expressionValue);
    }

    /**
     * Assigns a new value to the internal expression value and adjusts the {@code modified} and {@code undefined}
     * flags. Does not update any appearances nor apply / unapply decorations. Should be called from change handlers.
     */
    protected void modifyExpressionValue(String newExpressionValue) {
        this.value = null;
        this.expressionValue = newExpressionValue;

        setModified(true);
        setUndefined(isEmpty());
    }

    @Override
    public void addResolveExpressionHandler(ResolveExpressionHandler handler) {
        resolveExpressionHandlers.add(handler);
    }

    void toggleExpressionSupport(String expressionValue) {
        // TODO Find a way how to use the expression resolver in modals
        if (!isModal()) {
            if (supportsExpressions() && hasExpressionScheme(expressionValue)) {
                applyExpressionValue(expressionValue);
            } else {
                unapply(EXPRESSION);
            }
        }
    }

    void applyExpressionValue(String expressionValue) {
        ExpressionContext expressionContext = new ExpressionContext(expressionValue, expression -> {
            ResolveExpressionEvent ree = new ResolveExpressionEvent(expression);
            resolveExpressionHandlers.forEach(handler -> handler.onResolveExpression(ree));
        });
        apply(EXPRESSION, expressionContext);
    }

    boolean hasExpressionScheme(String value) {
        return value != null && value.contains("${") && value.indexOf("}") > 1;
    }

    boolean isModal() {
        // extra method to support unit tests
        return Dialog.isOpen() || Wizard.isOpen();
    }

    // ------------------------------------------------------ suggestion handler

    @Override
    public void registerSuggestHandler(SuggestHandler suggestHandler) {
        this.suggestHandler = suggestHandler;
        if (suggestHandler != null) {
            this.suggestHandler.setFormItem(this);
            apply(SUGGESTIONS, suggestHandler);
        } else {
            unapply(SUGGESTIONS);
        }
    }

    public void onSuggest(String suggestion) {
        // nop
    }

    // ------------------------------------------------------ flags and properties

    @Override
    public boolean isRestricted() {
        return restricted;
    }

    @Override
    public void setRestricted(boolean restricted) {
        if (this.restricted != restricted) {
            this.restricted = restricted;
            if (restricted) {
                apply(RESTRICTED);
            } else {
                unapply(RESTRICTED);
            }
        }
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }

    @Override
    public void setEnabled(boolean enabled) {
        if (this.enabled != enabled) {
            this.enabled = enabled;
            if (enabled) {
                apply(ENABLED);
            } else {
                unapply(ENABLED);
            }
        }
    }

    @Override
    public int getTabIndex() {
        Appearance<T> appearance = appearance(State.EDITING);
        return appearance != null ? appearance.getTabIndex() : -1;
    }

    @Override
    public void setTabIndex(int index) {
        Appearance<T> appearance = appearance(State.EDITING);
        if (appearance != null) {
            appearance.setTabIndex(index);
        }
    }

    @Override
    public void setAccessKey(char accessKey) {
        Appearance<T> appearance = appearance(State.EDITING);
        if (appearance != null) {
            appearance.setAccessKey(accessKey);
        }
    }

    @Override
    public void setFocus(boolean focus) {
        Appearance<T> appearance = appearance(State.EDITING);
        if (appearance != null) {
            appearance.setFocus(focus);
        }
    }

    @Override
    public String getLabel() {
        return label;
    }

    @Override
    public void setLabel(String label) {
        appearances.values().forEach(a -> a.setLabel(label));
    }

    @Override
    public boolean isRequired() {
        return required;
    }

    @Override
    public void setRequired(boolean required) {
        if (this.required != required) {
            this.required = required;
            if (required) {
                apply(REQUIRED);
            } else {
                unapply(REQUIRED);
            }
        }
    }

    @Override
    public final boolean isModified() {
        return modified;
    }

    @Override
    public void setModified(boolean modified) {
        this.modified = modified;
    }

    @Override
    public final boolean isUndefined() {
        return undefined;
    }

    @Override
    public void setUndefined(boolean undefined) {
        this.undefined = undefined;
    }

    @Override
    public boolean isDeprecated() {
        return deprecation != null && deprecation.isDefined();
    }

    @Override
    public void setDeprecated(Deprecation deprecation) {
        this.deprecation = deprecation;
        if (deprecation != null && deprecation.isDefined()) {
            apply(DEPRECATED, deprecation);
        } else {
            unapply(DEPRECATED);
        }
    }

    void setForm(Form form) {
        this.form = form;
    }

    @FunctionalInterface
    interface ExpressionCallback {

        void resolveExpression(String expression);
    }

    static class ExpressionContext {

        final String expression;
        final ExpressionCallback callback;

        ExpressionContext(String expression, ExpressionCallback callback) {
            this.expression = expression;
            this.callback = callback;
        }
    }
}