All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.wicketstuff.objectautocomplete.ObjectAutoCompleteField Maven / Gradle / Ivy

There is a newer version: 10.3.0
Show newest version
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.wicketstuff.objectautocomplete;

import org.apache.wicket.AttributeModifier;
import org.apache.wicket.Component;
import org.apache.wicket.ajax.AjaxEventBehavior;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
import org.apache.wicket.ajax.markup.html.AjaxFallbackLink;
import org.apache.wicket.markup.ComponentTag;
import org.apache.wicket.markup.html.WebComponent;
import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.form.FormComponentPanel;
import org.apache.wicket.markup.html.form.HiddenField;
import org.apache.wicket.markup.html.form.TextField;
import org.apache.wicket.markup.html.image.Image;
import org.apache.wicket.model.CompoundPropertyModel;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.IWrapModel;
import org.apache.wicket.model.Model;
import org.apache.wicket.model.PropertyModel;
import org.apache.wicket.util.string.Strings;
import org.apache.wicket.util.value.IValueMap;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Wicket component for selecting a single object of type T with an identifier of type I via
 * autocompletion. The textfield used for autocompletion is nothing more than a
 * search field where the autocomplete menu presents the search results. Selecting an entry
 * of the search results (either via keyboard or mouse click) "picks" an entity, whose id is stored
 * in the model of this component (So, the model object type of this model is I).
 * 
 * A subclass must provide the list of T-objects, which are presented in the autocompletion menu.
 * 
 * @author roland
 * @since May 21, 2008
 */
public class ObjectAutoCompleteField extends
	FormComponentPanel implements ObjectAutoCompleteCancelListener
{
	private static final long serialVersionUID = 1L;

	// Used for removing non-conformant span attributes
	final private static Set ALLOWED_SPAN_ATTRIBUTES = new HashSet(Arrays.asList(
		"wicket:id", "id", "class", "lang", "dir", "title", "style", "align", "onclick",
		"ondblclick", "onmousedown", "onmouseup", "onmouseover", "onmousemove", "onmouseout",
		"onkeypress", "onkeydown", "onkeyup"));

	// Listener to be notified on a selection change. These need not to be components
	private final List> selectionChangeListeners = new ArrayList>();

	// Remember old id in case a search operation is aborted
	private I selectedObjectId;

	// Used for state handling
	private I backupObjectId;
	private String backupText;

	// Textfield used to search for the object
	private TextField searchTextField;

	// Hiddenfield carrying the selected object id
	private HiddenField objectField;

	// whether the input field should be cleared on selection
	private final boolean clearInputOnSelection;

	/**
	 * Package scoped constructor to be used by the builder to create an auto completion fuild via
	 * the builder pattern. I.e. use
	 * {@link org.wicketstuff.objectautocomplete.ObjectAutoCompleteBuilder#build(String)} for
	 * creating an object auto completion component
	 * 
	 * @param pId
	 *            id of the component to add
	 * @param pModel
	 *            the model
	 * @param pBuilder
	 *            builder object used to create this field.
	 */
	ObjectAutoCompleteField(String pId, IModel pModel, ObjectAutoCompleteBuilder pBuilder)
	{
		super(pId, pModel);

		// Remember original object. This is the one we are working on.
		selectedObjectId = getModelObject();

		setOutputMarkupId(true);

		// Register ourself as a cancel listener to restore asn old id
		pBuilder.cancelListener(this);

		// Register all change selection listeners
		for (ObjectAutoCompleteSelectionChangeListener listener : pBuilder.selectionChangeListener)
		{
			registerForUpdateOnSelectionChange(listener);
		}

		// Search Text model contains the text selected
		Model searchTextModel = new Model();
		addSearchTextField(searchTextModel, pBuilder);
		addReadOnlyPanel(searchTextModel, pBuilder);
		clearInputOnSelection = pBuilder.clearInputOnSelection;
	}


	/**
	 * Register a listener that needs to be updated when the selection changes, i.e. the user
	 * selected an object from the suggestion list. The listener is called with the id-model and the
	 * ajax request target which can be used for updating one self
	 * 
	 * @param pListener
	 *            the listener to notify
	 */
	public void registerForUpdateOnSelectionChange(
		ObjectAutoCompleteSelectionChangeListener pListener)
	{
		selectionChangeListeners.add(pListener);
	}

	// ==========================================================================================================

	// the 'search part' if in lookup mode
	private void addSearchTextField(final Model pSearchTextModel,
		ObjectAutoCompleteBuilder pBuilder)
	{
		searchTextField = new TextField("search", pSearchTextModel)
		{
			private static final long serialVersionUID = 1L;

			@Override
			public boolean isVisible()
			{
				return isSearchMode();
			}
		};
		searchTextField.setOutputMarkupId(true);
		// this disables Firefox autocomplete
		searchTextField.add(AttributeModifier.append("autocomplete", "off"));

		objectField = new HiddenField("hiddenId", new PropertyModel(this, "selectedObjectId"));
		objectField.setOutputMarkupId(true);
		if (pBuilder.idType != null)
		{
			objectField.setType(pBuilder.idType);
		}
		add(objectField);

		searchTextField.add(pBuilder.buildBehavior(objectField), new ObjectUpdateBehavior()
		// newFormUpdateBehaviour(searchTextField)
		);

		add(searchTextField);
	}

	@Override
	protected IModel initModel()
	{
		@SuppressWarnings("unchecked")
		IModel model = (IModel)super.initModel();
		if (model instanceof IWrapModel)
		{
			IWrapModel iwModel = (IWrapModel)model;
			if (iwModel.getWrappedModel() instanceof CompoundPropertyModel)
			{
				@SuppressWarnings("unchecked")
				CompoundPropertyModel cpModel = (CompoundPropertyModel)iwModel.getWrappedModel();
				objectField.setModel(new PropertyModel(cpModel, getId()));
			}
		}
		return model;
	}

	// the 'read only part' if the object has been selected
	private void addReadOnlyPanel(final Model pSearchTextModel,
		ObjectAutoCompleteBuilder pBuilder)
	{
		final WebMarkupContainer wac = new WebMarkupContainer("readOnlyPanel")
		{
			private static final long serialVersionUID = 1L;

			@Override
			public boolean isVisible()
			{
				return !isSearchMode();
			}
		};
		wac.setOutputMarkupId(true);

		ObjectReadOnlyRenderer roRenderer = pBuilder.readOnlyObjectRenderer;

		Component objectReadOnlyComponent;
		if (roRenderer != null)
		{
			objectReadOnlyComponent = roRenderer.getObjectRenderer("selectedValue",
				new PropertyModel(this, "selectedObjectId"), pSearchTextModel);
		}
		else
		{
			objectReadOnlyComponent = new Label("selectedValue", pSearchTextModel);
		}
		objectReadOnlyComponent.setOutputMarkupId(true);

		AjaxFallbackLink deleteLink = new AjaxFallbackLink("searchLink")
		{
			private static final long serialVersionUID = 1L;

			@Override
			public void onClick(AjaxRequestTarget target)
			{
				changeToSearchMode(target);
			}
		};

		Component linkImage = new WebComponent("searchLinkImage").setVisible(false);
		if (pBuilder.imageResource != null || pBuilder.imageResourceReference != null)
		{
			linkImage = pBuilder.imageResource != null ? new Image("searchLinkImage",
				pBuilder.imageResource) : new Image("searchLinkImage",
				pBuilder.imageResourceReference);
			deleteLink.add(new Label(ObjectAutoCompleteBuilder.SEARCH_LINK_PANEL_ID).setVisible(false));
		}
		else if (pBuilder.searchLinkContent != null)
		{
			deleteLink.add(pBuilder.searchLinkContent);
		}
		else if (!pBuilder.unchangeable)
		{
			deleteLink.add(new Label(ObjectAutoCompleteBuilder.SEARCH_LINK_PANEL_ID,
				pBuilder.searchLinkText));
		}
		deleteLink.add(linkImage);

		if (pBuilder.searchOnClick)
		{
			deleteLink.setVisible(false);
			objectReadOnlyComponent.add(new AjaxEventBehavior("onclick")
			{
				private static final long serialVersionUID = 1L;

				@Override
				protected void onEvent(AjaxRequestTarget target)
				{
					changeToSearchMode(target);
				}
			});
		}
		wac.add(objectReadOnlyComponent);
		wac.add(deleteLink);
		add(wac);
	}

	private void changeToSearchMode(AjaxRequestTarget target)
	{
		backupObjectId = selectedObjectId;
		backupText = searchTextField.getModelObject();
		selectedObjectId = null;
		ObjectAutoCompleteField.this.setModelObject(null);
		if (target != null)
		{
			target.add(ObjectAutoCompleteField.this);
			String id = searchTextField.getMarkupId();
			target.appendJavaScript("Wicket.DOM.get('" + id + "').focus();" + "Wicket.DOM.get('" + id +
				"').select();");
		}
	}

	/**
	 * Callback called in case the user cancels a search via 'escape'
	 * 
	 * @param pTarget
	 *            target to which the components to update are added
	 */
	public void searchCanceled(AjaxRequestTarget pTarget, boolean pForceRestore)
	{
		if (backupObjectId != null)
		{
			if (Strings.isEmpty(searchTextField.getModelObject()) && !pForceRestore)
			{
				searchTextField.setModelObject(null);
				backupText = null;
				backupObjectId = null;
			}
			else if (backupText != null)
			{
				searchTextField.setModelObject(backupText);
			}
			selectedObjectId = backupObjectId;
			pTarget.add(ObjectAutoCompleteField.this);
		}
		else
		{
			clearSearchInput();
			pTarget.add(searchTextField);
		}
		notifyListeners(pTarget);
	}

	private void clearSearchInput()
	{
		searchTextField.setModelObject(null);
		selectedObjectId = null;
		searchTextField.clearInput();
		backupText = null;
	}

	// mode detection based on the existance of a seleced model
	private boolean isSearchMode()
	{
		return selectedObjectId == null;
	}

	/**
	 * Called when form is submitted. We are simply store the object remembered internally.
	 */
	@Override
	public void convertInput()
	{
		setConvertedInput(selectedObjectId);
	}

	@Override
	// ensure that this component is embedded in a  ... 
	protected void onComponentTag(ComponentTag pTag)
	{
		super.onComponentTag(pTag);
		pTag.setName("span");
		// Remove non-conformant  attributes
		IValueMap attribMap = pTag.getAttributes();
		Iterator> attrIterator = attribMap.entrySet().iterator();
		while (attrIterator.hasNext())
		{
			Map.Entry entry = attrIterator.next();
			String key = entry.getKey().toLowerCase();
			if (!ALLOWED_SPAN_ATTRIBUTES.contains(key))
			{
				attrIterator.remove();
			}
		}
	}

	private void notifyListeners(AjaxRequestTarget pTarget)
	{
		for (ObjectAutoCompleteSelectionChangeListener listener : selectionChangeListeners)
		{
			listener.selectionChanged(pTarget, objectField.getModel());
		}

	}

	/**
	 * Access to internal text field which can be used for adding some decorations or behaviours
	 * (like JS-Handler or specific CSS). Please be conservative in what you are doing, i.e. do not
	 * modify its model.
	 * 
	 * Note: This method might vanish until the first release, it's probably better to provide
	 * delegation methods.
	 * 
	 * @return textfield textfield holding the search input
	 */
	public TextField getSearchTextField()
	{
		return searchTextField;
	}

	// =========================================================================================

	// Behaviour, when user leaves the input field.
	class ObjectUpdateBehavior extends AjaxEventBehavior
	{
		private static final long serialVersionUID = 1L;

		ObjectUpdateBehavior()
		{
            // uses a custom event name to avoid clashes with 'change' event
			super("objectchange");
		}

		@Override
		protected void onEvent(AjaxRequestTarget target)
		{
			objectField.processInput();
			searchTextField.processInput();
			target.add(ObjectAutoCompleteField.this);
			notifyListeners(target);
			if (clearInputOnSelection)
			{
				clearSearchInput();
			}
		}

		@Override
		protected void updateAjaxAttributes(AjaxRequestAttributes attributes)
		{
			super.updateAjaxAttributes(attributes);

			attributes.setMethod(AjaxRequestAttributes.Method.POST);

			String searchTextFieldMarkupId = searchTextField.getMarkupId();
			String objectFieldMarkupId = objectField.getMarkupId();
			String deps = "return [].concat(Wicket.Form.serializeElement('"+searchTextFieldMarkupId+"')).concat(Wicket.Form.serializeElement('"+objectFieldMarkupId+"'))";
			attributes.getDynamicExtraParameters().add(deps);
		}

	}

}