Java tutorial
/* * 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.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.gwt.core.client.GWT; import com.google.gwt.safehtml.shared.SafeHtml; import com.google.web.bindery.event.shared.HandlerRegistration; import elemental2.dom.HTMLDivElement; import elemental2.dom.HTMLElement; import elemental2.dom.HTMLFieldSetElement; import elemental2.dom.HTMLHRElement; import elemental2.dom.HTMLLegendElement; import elemental2.dom.HTMLUListElement; import elemental2.dom.KeyboardEvent; import org.jboss.gwt.elemento.core.Elements; import org.jboss.gwt.elemento.core.EventCallbackFn; import org.jboss.gwt.elemento.core.LazyElement; import org.jboss.hal.ballroom.Attachable; import org.jboss.hal.ballroom.EmptyState; import org.jboss.hal.resources.Constants; import org.jboss.hal.resources.Icons; import org.jboss.hal.resources.Ids; import org.jboss.hal.resources.Messages; import static elemental2.dom.DomGlobal.setTimeout; import static java.util.stream.Collectors.toList; import static org.jboss.gwt.elemento.core.Elements.*; import static org.jboss.gwt.elemento.core.EventType.bind; import static org.jboss.gwt.elemento.core.EventType.click; import static org.jboss.gwt.elemento.core.EventType.keyup; import static org.jboss.hal.ballroom.form.Form.Operation.*; import static org.jboss.hal.ballroom.form.Form.State.EDITING; import static org.jboss.hal.ballroom.form.Form.State.EMPTY; import static org.jboss.hal.ballroom.form.Form.State.READONLY; import static org.jboss.hal.resources.CSS.*; import static org.jboss.hal.resources.UIConstants.MEDIUM_TIMEOUT; /** * A generic form with some reasonable UI defaults. Please note that all form items and help texts must be setup * before this form is added {@linkplain #element()} as an element} to the DOM. * <p> * The form consists of {@linkplain FormLinks links} and three sections: * <ul> * <li>empty</li> * <li>read-only</li> * <li>editing</li> * </ul> */ public abstract class AbstractForm<T> extends LazyElement implements Form<T> { private static final Constants CONSTANTS = GWT.create(Constants.class); private static final Messages MESSAGES = GWT.create(Messages.class); private static final String MODEL_MUST_NOT_BE_NULL = "Model must not be null in "; private static final String MODEL_MUST_NOT_BE_UNDEFINED = "Model must not be undefined in "; private static final String NOT_INITIALIZED = "Form element not initialized. Please add this form to the DOM before calling any of the form operations"; private final String id; private final StateMachine stateMachine; private final DataMapping<T> dataMapping; private final LinkedHashMap<State, HTMLElement> panels; // Contains *all* form items. Do not use this field directly. // Instead use getFormItems() or getBoundFormItems() private final LinkedHashMap<String, FormItem> formItems; private final Set<String> unboundItems; private final LinkedHashMap<String, SafeHtml> helpTexts; private final List<FormValidation> formValidations; private boolean separateOptionalFields; private T model; private final EmptyState emptyState; protected FormLinks<T> formLinks; private HTMLDivElement errorPanel; private HTMLElement errorMessage; private HTMLUListElement errorMessages; private EventCallbackFn<KeyboardEvent> escCallback; private HandlerRegistration escRegistration; // accessible in subclasses protected SaveCallback<T> saveCallback; protected CancelCallback<T> cancelCallback; protected PrepareReset<T> prepareReset; protected PrepareRemove<T> prepareRemove; // ------------------------------------------------------ initialization public AbstractForm(String id, StateMachine stateMachine, DataMapping<T> dataMapping, EmptyState emptyState) { this.id = id; this.stateMachine = stateMachine; this.dataMapping = dataMapping; this.emptyState = emptyState; this.panels = new LinkedHashMap<>(); this.formItems = new LinkedHashMap<>(); this.unboundItems = new HashSet<>(); this.helpTexts = new LinkedHashMap<>(); this.formValidations = new ArrayList<>(); } protected void addFormItem(FormItem formItem, FormItem... formItems) { for (FormItem item : Lists.asList(formItem, formItems)) { this.formItems.put(item.getName(), item); item.setId(Ids.build(id, item.getName())); if (item instanceof AbstractFormItem) { ((AbstractFormItem) item).setForm(this); } } } protected void separateOptionalFields(boolean separateOptionalFields) { this.separateOptionalFields = separateOptionalFields; } protected void markAsUnbound(String name) { unboundItems.add(name); } protected void addHelp(String label, SafeHtml description) { helpTexts.put(label, description); } @Override public void addFormValidation(FormValidation<T> formValidation) { formValidations.add(formValidation); } // ------------------------------------------------------ ui setup @Override protected HTMLElement createElement() { HTMLElement section = section().id(id).css(formSection).get(); formLinks = new FormLinks<>(this, stateMachine, helpTexts, event -> edit(getModel()), event -> { if (prepareReset != null) { prepareReset.beforeReset(this); } else { reset(); } }, event -> { if (prepareRemove != null) { prepareRemove.beforeRemove(this); } else { remove(); } }); section.appendChild(formLinks.element()); errorPanel = div().css(alert, alertDanger).add(span().css(Icons.ERROR)).add(errorMessage = span().get()) .add(errorMessages = ul().get()).get(); clearErrors(); if (stateMachine.supports(EMPTY)) { panels.put(EMPTY, emptyState.element()); } if (stateMachine.supports(READONLY)) { panels.put(READONLY, viewPanel()); } if (stateMachine.supports(EDITING)) { panels.put(EDITING, editPanel()); } for (HTMLElement element : panels.values()) { section.appendChild(element); } if (stateMachine.supports(EDIT)) { escCallback = (KeyboardEvent event) -> { if ("Escape".equals(event.key) && //NON-NLS stateMachine.current() == EDITING && panels.get(EDITING) != null && Elements.isVisible(panels.get(EDITING))) { event.preventDefault(); cancel(); } }; } State current = stateMachine.current(); if (current != null) { flip(current); } else { flip(panels.keySet().iterator().next()); } return section; } private HTMLElement viewPanel() { HTMLDivElement viewPanel = div().id(Ids.build(id, READONLY.name().toLowerCase())) .css(form, formHorizontal, readonly).get(); for (Iterator<FormItem> iterator = getFormItems().iterator(); iterator.hasNext();) { FormItem formItem = iterator.next(); viewPanel.appendChild(formItem.element(READONLY)); if (iterator.hasNext()) { HTMLHRElement hr = hr().css(separator).get(); viewPanel.appendChild(hr); } } return viewPanel; } private HTMLElement editPanel() { HTMLElement editPanel = div().id(Ids.build(id, EDITING.name().toLowerCase())) .css(form, formHorizontal, editing).get(); editPanel.appendChild(errorPanel); boolean hasRequiredField = false; boolean hasOptionalField = false; for (FormItem formItem : getFormItems()) { if (formItem.isRequired() || !separateOptionalFields) { editPanel.appendChild(formItem.element(EDITING)); } hasRequiredField = hasRequiredField || formItem.isRequired(); hasOptionalField = hasOptionalField || !formItem.isRequired(); } if (hasRequiredField) { editPanel.appendChild(div().css(formGroup) .add(div().css(halFormOffset).add(span().css(helpBlock).innerHtml(MESSAGES.requiredHelp()))) .get()); } // if separateOptionalFields=true and there are non-required attributes, they are placed in a collapsible // panel if (separateOptionalFields && hasOptionalField) { List<HTMLElement> optionalFields = new ArrayList<>(); HTMLFieldSetElement fieldsetElement = fieldset().css(fieldsSectionPf).get(); HTMLElement expanderElement = span() .css(fontAwesome("angle-right"), fontAwesome("angle-down"), fieldSectionTogglePf).get(); // as we add fa-angle-right and fa-angle-down, remove the later so the angle-right becomes visible expanderElement.classList.remove(faAngleDown); HTMLLegendElement legend = legend().css(fieldsSectionHeaderPf).add(expanderElement) .add(a().css(fieldSectionTogglePf, clickable).textContent(CONSTANTS.optionalFields()).on(click, event -> { // toggle the fa-angle-down to show either angle-right or angle-down expanderElement.classList.toggle(faAngleDown); optionalFields.forEach(field -> setVisible(field, expanderElement.classList.contains(faAngleDown))); })) .get(); fieldsetElement.appendChild(legend); for (FormItem formItem : getFormItems()) { if (!formItem.isRequired()) { HTMLElement field = formItem.element(EDITING); optionalFields.add(field); fieldsetElement.appendChild(field); setVisible(field, false); } } editPanel.appendChild(fieldsetElement); } HTMLElement buttons = div().css(formGroup, formButtons) .add(div().css(halFormOffset) .add(div().css(pullRight) .add(button().css(btn, btnHal, btnDefault).textContent(CONSTANTS.cancel()).on(click, event -> cancel())) .add(button().css(btn, btnHal, btnPrimary).textContent(CONSTANTS.save()).on(click, event -> save())))) .get(); editPanel.appendChild(buttons); return editPanel; } @Override public void attach() { getFormItems().forEach(Attachable::attach); } @Override public void detach() { stateMachine.reset(); getFormItems().forEach(Attachable::detach); } // ------------------------------------------------------ form operations /** * Executes the {@link Operation#VIEW} operation and calls {@link * DataMapping#populateFormItems(Object, Form)} if the form is not {@linkplain #isUndefined() undefined}. * * @param model the model to view. */ @Override public final void view(T model) { if (!initialized()) { throw new IllegalStateException(NOT_INITIALIZED); } this.model = model; stateExec(VIEW, isUndefined() ? EMPTY : READONLY); if (!isUndefined()) { dataMapping.populateFormItems(model, this); } } /** * Removes the model reference, executes the {@link Operation#CLEAR} operation and * calls {@link DataMapping#clearFormItems(Form)}. */ @Override public void clear() { if (!initialized()) { throw new IllegalStateException(NOT_INITIALIZED); } this.model = null; stateExec(CLEAR); clearErrors(); dataMapping.clearFormItems(this); } /** * Executes the {@link Operation#EDIT} operation and calls {@link DataMapping#newModel(Object, Form)} if the model * is {@linkplain #isTransient() transitive} otherwise {@link DataMapping#populateFormItems(Object, Form)}. * * @param model the model to edit. */ @Override public final void edit(T model) { if (!initialized()) { throw new IllegalStateException(NOT_INITIALIZED); } this.model = model; stateExec(EDIT); clearErrors(); if (isTransient()) { dataMapping.newModel(model, this); } else { dataMapping.populateFormItems(model, this); for (FormItem formItem : getBoundFormItems()) { formItem.setModified(false); } } } /** * Upon successful validation, executes the {@link Operation#SAVE} operation, * calls {@link DataMapping#persistModel(Object, Form)} and finally calls the registered {@linkplain * SaveCallback save callback} (if any). */ @Override public final boolean save() { if (!initialized()) { throw new IllegalStateException(NOT_INITIALIZED); } boolean valid = validate(); if (valid) { stateExec(SAVE); // switch state before data mapping! dataMapping.persistModel(model, this); if (saveCallback != null) { saveCallback.onSave(this, getChangedValues()); } } return valid; } @Override public void setSaveCallback(SaveCallback<T> saveCallback) { this.saveCallback = saveCallback; } protected Map<String, Object> getChangedValues() { Map<String, Object> changed = new HashMap<>(); for (FormItem formItem : getBoundFormItems()) { if (formItem.isModified()) { if (formItem.isExpressionValue()) { changed.put(formItem.getName(), formItem.getExpressionValue()); } else { changed.put(formItem.getName(), formItem.getValue()); } } } return changed; } /** * Executes the {@link Operation#CANCEL} operation and calls the registered * {@linkplain CancelCallback cancel callback} (if any). */ @Override public final void cancel() { if (!initialized()) { throw new IllegalStateException(NOT_INITIALIZED); } if (getModel() == null) { throw new NullPointerException(MODEL_MUST_NOT_BE_NULL + formId() + ".cancel()"); } stateExec(CANCEL); dataMapping.populateFormItems(model, this); // restore persisted model if (cancelCallback != null) { cancelCallback.onCancel(this); } } @Override public void setCancelCallback(CancelCallback<T> cancelCallback) { this.cancelCallback = cancelCallback; } public void setPrepareReset(PrepareReset<T> prepareReset) { this.prepareReset = prepareReset; } /** * Executes the {@link Operation#RESET} operation. */ @Override public final void reset() { if (!initialized()) { throw new IllegalStateException(NOT_INITIALIZED); } if (isUndefined()) { throw new NullPointerException(MODEL_MUST_NOT_BE_UNDEFINED + formId() + ".reset()"); } stateExec(RESET); } @Override public void setPrepareRemove(PrepareRemove<T> removeCallback) { this.prepareRemove = removeCallback; } /** * Removes the model reference and executes the {@link Operation#REMOVE} operation. */ @Override public void remove() { if (!initialized()) { throw new IllegalStateException(NOT_INITIALIZED); } if (isUndefined()) { throw new NullPointerException(MODEL_MUST_NOT_BE_UNDEFINED + formId() + ".remove()"); } this.model = null; stateExec(REMOVE); } private String formId() { return "form(" + id + ")"; //NON-NLS } // ------------------------------------------------------ state transition private void stateExec(Operation operation) { stateExec(operation, null); } private <C> void stateExec(Operation operation, C context) { stateMachine.execute(operation, context); prepare(stateMachine.current()); flip(stateMachine.current()); } protected void prepare(State state) { switch (state) { case EMPTY: formLinks.setVisible(false, false, false, false); prepareEmptyState(); break; case READONLY: formLinks.setVisible(model != null && stateMachine.supports(EDIT), model != null && stateMachine.supports(RESET), model != null && stateMachine.supports(REMOVE), true); prepareViewState(); break; case EDITING: formLinks.setVisible(false, false, false, true); prepareEditState(); break; default: break; } } /** * Gives subclasses a way to prepare the empty state. Called after the state has changed, but before the UI flips * to the new state. */ @SuppressWarnings("WeakerAccess") protected void prepareEmptyState() { } /** * Gives subclasses a way to prepare the view state. Called after the state has changed, but before the UI flips * to the new state. */ @SuppressWarnings("WeakerAccess") protected void prepareViewState() { } /** * Gives subclasses a way to prepare the edit state. Called after the state has changed, but before the UI flips * to the new state. */ @SuppressWarnings("WeakerAccess") protected void prepareEditState() { } protected void flip(State state) { // exit with ESC handler switch (state) { case EMPTY: case READONLY: if (escRegistration != null && panels.get(EDITING) != null) { escRegistration.removeHandler(); } break; case EDITING: if (!Iterables.isEmpty(getFormItems())) { setTimeout((o) -> getFormItems().iterator().next().setFocus(true), MEDIUM_TIMEOUT); } if (escCallback != null && panels.get(EDITING) != null) { // Exit *this* edit state by pressing ESC escRegistration = bind(panels.get(EDITING), keyup, escCallback); } break; default: break; } panels.values().stream().filter(panel -> panel != panels.get(state)) .forEach(panel -> setVisible(panel, false)); setVisible(panels.get(state), true); } // ------------------------------------------------------ properties @Override public String getId() { return id; } @Override public T getModel() { return model; } @Override public StateMachine getStateMachine() { return stateMachine; } @Override @SuppressWarnings("unchecked") public <I> FormItem<I> getFormItem(String name) { return formItems.get(name); } @Override public Iterable<FormItem> getBoundFormItems() { return formItems.values().stream().filter(formItem -> !unboundItems.contains(formItem.getName())) .collect(toList()); } @Override public Iterable<FormItem> getFormItems() { return ImmutableList.copyOf(formItems.values()); } // ------------------------------------------------------ validation @SuppressWarnings("unchecked") protected boolean validate() { boolean valid = true; clearErrors(); // validate form items for (FormItem formItem : getFormItems()) { if (!formItem.validate()) { valid = false; } } // validate form on its own List<String> messages = new ArrayList<>(); for (FormValidation validationHandler : formValidations) { ValidationResult validationResult = validationHandler.validate(this); if (!validationResult.isValid()) { messages.add(validationResult.getMessage()); } } if (!messages.isEmpty()) { valid = false; showErrors(messages); } return valid; } private void clearErrors() { for (FormItem formItem : getFormItems()) { formItem.clearError(); } errorMessage.textContent = ""; Elements.removeChildrenFrom(errorMessages); setVisible(errorPanel, false); } private void showErrors(List<String> messages) { if (!messages.isEmpty()) { if (messages.size() == 1) { errorMessage.textContent = messages.get(0); Elements.removeChildrenFrom(errorMessages); } else { errorMessage.textContent = CONSTANTS.formErrors(); for (String message : messages) { errorMessages.appendChild(li().textContent(message).get()); } } setVisible(errorPanel, true); } } }