/* (c) 2014 - 2016 Open Source Geospatial Foundation - all rights reserved * (c) 2001 - 2013 OpenPlans * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.web.wicket; import java.io.Serializable; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import org.apache.wicket.AttributeModifier; import org.apache.wicket.Component; import org.apache.wicket.ajax.AjaxRequestTarget; import org.apache.wicket.ajax.form.AjaxFormComponentUpdatingBehavior; import org.apache.wicket.ajax.markup.html.AjaxLink; import org.apache.wicket.ajax.markup.html.form.AjaxButton; import org.apache.wicket.extensions.markup.html.repeater.util.SortParam; import org.apache.wicket.markup.ComponentTag; import org.apache.wicket.markup.html.WebMarkupContainer; import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.form.CheckBox; import org.apache.wicket.markup.html.form.Form; import org.apache.wicket.markup.html.form.FormComponent; import org.apache.wicket.markup.html.form.TextField; import org.apache.wicket.markup.html.list.ListItem; import org.apache.wicket.markup.html.list.ListView; import org.apache.wicket.markup.html.panel.Fragment; import org.apache.wicket.markup.html.panel.Panel; import org.apache.wicket.markup.repeater.DefaultItemReuseStrategy; import org.apache.wicket.markup.repeater.IItemReuseStrategy; import org.apache.wicket.markup.repeater.Item; import org.apache.wicket.markup.repeater.OddEvenItem; import org.apache.wicket.markup.repeater.ReuseIfModelsEqualStrategy; import org.apache.wicket.markup.repeater.data.DataView; import org.apache.wicket.model.IModel; import org.apache.wicket.model.Model; import org.apache.wicket.model.PropertyModel; import org.apache.wicket.model.ResourceModel; import org.geoserver.web.wicket.GeoServerDataProvider.Property; /** * An abstract filterable, sortable, pageable table with associated filtering form and paging * navigator. * <p> * The construction of the page is driven by the properties returned by a * {@link GeoServerDataProvider}, subclasses only need to build a component for each property by * implementing the {@link #getComponentForProperty(String, IModel, Property)} method * * @param <T> */ public abstract class GeoServerTablePanel<T> extends Panel { private static final long serialVersionUID = -5275268446479549108L; private static final int DEFAULT_ITEMS_PER_PAGE = 25; // filter form components TextField<String> filter; // table components DataView<T> dataView; WebMarkupContainer listContainer; PagerDelegate pagerDelegate; Pager navigatorTop; Pager navigatorBottom; GeoServerDataProvider<T> dataProvider; Form<?> filterForm; CheckBox selectAll; AjaxButton hiddenSubmit; boolean sortable = true; boolean selectable = true; /** * An array of the selected items in the current page. Gets wiped out each * time the current page, the sorting or the filtering changes. */ boolean[] selection; boolean selectAllValue; /** * Builds a non selectable table */ public GeoServerTablePanel(final String id, final GeoServerDataProvider<T> dataProvider) { this(id, dataProvider, false); } /** * Builds a new table panel */ public GeoServerTablePanel(final String id, final GeoServerDataProvider<T> dataProvider, final boolean selectable) { super(id); this.dataProvider = dataProvider; // prepare the selection array selection = new boolean[DEFAULT_ITEMS_PER_PAGE]; // layer container used for ajax-y udpates of the table listContainer = new WebMarkupContainer("listContainer"); // build the filter form filterForm = new Form<>("filterForm"); filterForm.setOutputMarkupId(true); add(filterForm); filter = new TextField<String>("filter", new Model<String>()) { private static final long serialVersionUID = -1252520208030081584L; @Override protected void onComponentTag(ComponentTag tag) { super.onComponentTag(tag); tag.put("onkeypress", "if(event.keyCode == 13) {document.getElementById('" + hiddenSubmit.getMarkupId() + "').click();return false;}"); } }; filterForm.add(filter); filter.add(AttributeModifier.replace("title", String.valueOf(new ResourceModel( "GeoServerTablePanel.search", "Search").getObject()))); filterForm.add(hiddenSubmit = hiddenSubmit()); filterForm.setDefaultButton(hiddenSubmit); // setup the table listContainer.setOutputMarkupId(true); add(listContainer); dataView = new DataView<T>("items", dataProvider) { private static final long serialVersionUID = 7201317388415148823L; @Override protected Item<T> newItem(String id, int index, IModel<T> model) { OddEvenItem<T> item = new OddEvenItem<T>(id, index, model); item.setOutputMarkupId(true); return item; } @Override protected void populateItem(Item<T> item) { final IModel<T> itemModel = item.getModel(); // add row selector (visible only if selection is active) WebMarkupContainer cnt = new WebMarkupContainer("selectItemContainer"); cnt.add(selectOneCheckbox(item)); cnt.setVisible(selectable); item.add(cnt); buildRowListView(dataProvider, item, itemModel); } }; dataView.setItemReuseStrategy(ReuseIfModelsEqualStrategy.getInstance()); listContainer.add(dataView); // add select all checkbox WebMarkupContainer cnt = new WebMarkupContainer("selectAllContainer"); cnt.add(selectAll = selectAllCheckbox()); cnt.setVisible(selectable); listContainer.add(cnt); // add the sorting links listContainer.add(buildLinksListView(dataProvider)); // add the paging navigator and set the items per page dataView.setItemsPerPage(DEFAULT_ITEMS_PER_PAGE); pagerDelegate = new PagerDelegate(); filterForm.add(navigatorTop = new Pager("navigatorTop")); navigatorTop.setOutputMarkupId(true); add(navigatorBottom = new Pager("navigatorBottom")); navigatorBottom.setOutputMarkupId(true); } protected ListView<Property<T>> buildLinksListView(final GeoServerDataProvider<T> dataProvider) { return new ListView<Property<T>>("sortableLinks", dataProvider.getVisibleProperties()) { private static final long serialVersionUID = -7565457802398721254L; @Override protected void populateItem(ListItem<Property<T>> item) { Property<T> property = (Property<T>) item.getModelObject(); // build a sortable link if the property is sortable, a label otherwise IModel<String> titleModel = getPropertyTitle(property); if (sortable && property.getComparator() != null) { Fragment f = new Fragment("header", "sortableHeader", GeoServerTablePanel.this); AjaxLink<Property<T>> link = sortLink(dataProvider, item); link.add(new Label("label", titleModel)); f.add(link); item.add(f); } else { item.add(new Label("header", titleModel)); } } }; } protected void buildRowListView(final GeoServerDataProvider<T> dataProvider, Item<T> item, final IModel<T> itemModel) { // create one component per viewable property ListView<Property<T>> items = new ListView<Property<T>>("itemProperties", dataProvider.getVisibleProperties()) { private static final long serialVersionUID = -4552413955986008990L; @Override protected void populateItem(ListItem<Property<T>> item) { Property<T> property = item.getModelObject(); Component component = getComponentForProperty("component", itemModel, property); if (component == null) { // show a plain label if the the subclass did not create any component component = new Label("component", property.getModel(itemModel)); } else if (!"component".equals(component.getId())) { // add some checks for the id, the error message // that wicket returns in case of mismatch is not // that helpful throw new IllegalArgumentException("getComponentForProperty asked " + "to build a component " + "with id = 'component' " + "for property '" + property.getName() + "', but got '" + component.getId() + "' instead"); } item.add(component); onPopulateItem(property, item); } }; items.setReuseItems(true); item.add(items); } /** * Sets the item reuse strategy for the table. Should be {@link ReuseIfModelsEqualStrategy} if * you're building an editable table, {@link DefaultItemReuseStrategy} otherwise */ public void setItemReuseStrategy(IItemReuseStrategy strategy) { dataView.setItemReuseStrategy(strategy); } /** * Whether this table will have sortable headers, or not * @param sortable */ public void setSortable(boolean sortable) { this.sortable = sortable; } /** * Returns pager above the table * */ public Component getTopPager() { return navigatorTop; } /** * Returns the pager below the table * */ public Component getBottomPager() { return navigatorBottom; } /** * Returns the data provider feeding this table * */ public GeoServerDataProvider<T> getDataProvider() { return dataProvider; } /** * Called each time selection checkbox changes state due to a user action. * By default it does nothing, subclasses can implement this to provide * extra behavior * @param target */ protected void onSelectionUpdate(AjaxRequestTarget target) { // by default do nothing } /** * Returns a model for this property title. Default behaviour is to lookup for a * resource name <page>.th.<propertyName> * @param property * */ protected IModel<String> getPropertyTitle(Property<T> property) { ResourceModel resMod = new ResourceModel("th." + property.getName(), property.getName()); return resMod; } /** * @return the number of items selected in the current page */ public int getNumSelected() { int selected = 0; for (boolean itemSelected : selection) { if (itemSelected) { selected++; } } return selected; } /** * Returns the items that have been selected by the user * */ @SuppressWarnings("unchecked") public List<T> getSelection() { List<T> result = new ArrayList<T>(); int i = 0; for (Iterator<Component> it = dataView.iterator(); it.hasNext();) { Component item = it.next(); if(selection[i]) { result.add((T) item.getDefaultModelObject()); } i++; } return result; } CheckBox selectAllCheckbox() { CheckBox sa = new CheckBox("selectAll", new PropertyModel<Boolean>(this, "selectAllValue")); sa.setOutputMarkupId(true); sa.add(new AjaxFormComponentUpdatingBehavior("click") { private static final long serialVersionUID = 1154921156065269691L; @Override protected void onUpdate(AjaxRequestTarget target) { // select all the checkboxes setSelection(selectAllValue); // update table and the checkbox itself target.add(getComponent()); target.add(listContainer); // allow subclasses to play on this change as well onSelectionUpdate(target); } }); return sa; } protected CheckBox selectOneCheckbox(Item<T> item) { CheckBox cb = new CheckBox("selectItem", new SelectionModel(item.getIndex())); cb.setOutputMarkupId(true); cb.setVisible(selectable); cb.add(new AjaxFormComponentUpdatingBehavior("click") { private static final long serialVersionUID = -2419184741329470638L; @Override protected void onUpdate(AjaxRequestTarget target) { if(Boolean.FALSE.equals(getComponent().getDefaultModelObject())) { selectAllValue = false; target.add(selectAll); } onSelectionUpdate(target); } }); return cb; } /** * When set to false, will prevent the selection checkboxes from showing up * @param selectable */ public void setSelectable(boolean selectable) { this.selectable = selectable; selectAll.setVisible(selectable); } void setSelection(boolean selected) { for (int i = 0; i < selection.length; i++) { selection[i] = selected; } selectAllValue = selected; } /** * Clears the current selection */ public void clearSelection() { setSelection(false); } /** * Selects all the items in the current page */ public void selectAll() { setSelection(true); } /** * Selects a single item by object. */ public void selectObject(T object) { int i = 0; for (Iterator<Component> it = dataView.iterator(); it.hasNext();) { @SuppressWarnings("unchecked") Item<T> item = (Item<T>) it.next(); if (object.equals(item.getModelObject())) { selection[i] = true; return; } i++; } } /** * Selects a single item by index. */ public void selectIndex(int i) { selection[i] = true; } /** * The hidden button that will submit the form when the user * presses enter in the text field */ AjaxButton hiddenSubmit() { return new AjaxButton("submit") { static final long serialVersionUID = 5334592790005438960L; @Override protected void onSubmit(AjaxRequestTarget target, Form<?> form) { updateFilter(target, filter.getDefaultModelObjectAsString()); } }; } /** * Number of visible items per page, should the default {@link #DEFAULT_ITEMS_PER_PAGE} not * satisfy the programmer needs. Calling this will wipe out the selection * * @param items */ public void setItemsPerPage(int items) { dataView.setItemsPerPage(items); selection = new boolean[items]; } /** * Enables/disables filtering for this table. When no filtering is enabled, the top form with * the top pager and the search box will disappear. Returns self for chaining. */ public GeoServerTablePanel<T> setFilterable(boolean filterable) { filterForm.setVisible(filterable); return this; } /** * Builds a sort link that will force sorting on a certain column, and flip it to the other * direction when clicked again */ <S> AjaxLink<S> sortLink(final GeoServerDataProvider<T> dataProvider, ListItem<S> item) { return new AjaxLink<S>("link", item.getModel()) { private static final long serialVersionUID = -6180419488076488737L; @Override public void onClick(AjaxRequestTarget target) { SortParam<?> currSort = dataProvider.getSort(); @SuppressWarnings("unchecked") Property<T> property = (Property<T>) getModelObject(); if (currSort == null || !property.getName().equals(currSort.getProperty())) { dataProvider.setSort(new SortParam<Object>(property.getName(), true)); } else { dataProvider.setSort(new SortParam<Object>(property.getName(), !currSort.isAscending())); } setSelection(false); target.add(listContainer); } }; } /** * Parses the keywords and sets them into the data provider, forces update of the components * that need to as a result of the different filtering */ private void updateFilter(AjaxRequestTarget target, String flatKeywords) { if ("".equals(flatKeywords)) { dataProvider.setKeywords(null); filter.setModelObject(""); dataView.setCurrentPage(0); } else { String[] keywords = flatKeywords.split("\\s+"); dataProvider.setKeywords(keywords); dataView.setCurrentPage(0); } pagerDelegate.updateMatched(); navigatorTop.updateMatched(); navigatorBottom.updateMatched(); setSelection(false); target.add(listContainer); target.add(navigatorTop); target.add(navigatorBottom); } /** * Sets back to the first page, clears the selection and */ public void reset() { dataView.setCurrentPage(0); clearSelection(); dataProvider.setSort(null); } /** * Turns filtering abilities on/off. */ public void setFilterVisible(boolean filterVisible) { filterForm.setVisible(filterVisible); } public void processInputs() { this.visitChildren(FormComponent.class, (component, visit) -> { ((FormComponent<?>) component).processInput(); visit.dontGoDeeper(); }); } /** * Returns the component that will represent a property of a table item. Usually it should be a * label, or a link, but you can return pretty much everything. The subclass can also return null, * in that case a label will be created * * @param itemModel * @param property * */ protected abstract Component getComponentForProperty(String id, IModel<T> itemModel, Property<T> property); /** * Called each time a new table item/column is created. * <p> * By default this method does nothing, subclasses may override for instance to add an attribute * to the <td> element created for the column. * </p> */ protected void onPopulateItem(Property<T> property, ListItem<Property<T>> item) { } IModel<String> showingAllRecords(long first, long last, long size) { return new ParamResourceModel("showingAllRecords", this, first, last, size); } IModel<String> matchedXOutOfY(long first, long last, long size, long fullSize) { return new ParamResourceModel("matchedXOutOfY", this, first, last, size, fullSize); } protected class PagerDelegate implements Serializable { private static final long serialVersionUID = -6928477338531850338L; long fullSize, size, first, last; public PagerDelegate() { updateMatched(); } /** * Updates the label given the current page and filtering status */ void updateMatched() { fullSize = dataProvider.fullSize(); first = first(fullSize); last = last(fullSize); if (dataProvider.getKeywords() != null) { size = dataProvider.size(); } } public IModel<String> model() { if (dataProvider.getKeywords() == null) { return showingAllRecords(first, last, fullSize); } else { return matchedXOutOfY(first, last, size, fullSize); } } /** * User oriented index of the first item in the current page */ long first(long fullSize) { long size = fullSize; if (dataProvider.getKeywords() != null) { size = dataView.getDataProvider().size(); } if (size > 0) return dataView.getItemsPerPage() * dataView.getCurrentPage() + 1; else return 0; } /** * User oriented index of the last item in the current page */ long last(long fullSize) { long count = dataProvider.getKeywords() != null ? dataView.getPageCount() : optGetPageCount(fullSize); long page = dataView.getCurrentPage(); if (page < (count - 1)) return dataView.getItemsPerPage() * (page + 1); else { return dataProvider.getKeywords() != null ? dataView.getDataProvider().size() : fullSize; } } long optGetPageCount(long total) { long page = dataView.getItemsPerPage(); long count = total / page; if (page * count < total) { count++; } return count; } } /** * The two pages in the table panel. Includes a paging navigator and a status label telling the * user what she is seeing */ protected class Pager extends Panel { private static final long serialVersionUID = 6128188748404971154L; GeoServerPagingNavigator navigator; Label matched; Pager(String id) { super(id); add(navigator = updatingPagingNavigator()); add(matched = new Label("filterMatch", new Model<String>())); updateMatched(); } /** * Builds a paging navigator that will update both of the labels when the page changes. */ private GeoServerPagingNavigator updatingPagingNavigator() { return new GeoServerPagingNavigator("navigator", dataView) { private static final long serialVersionUID = -1795278469204272385L; @Override protected void onAjaxEvent(AjaxRequestTarget target) { super.onAjaxEvent(target); setSelection(false); pagerDelegate.updateMatched(); navigatorTop.updateMatched(); navigatorBottom.updateMatched(); target.add(navigatorTop); target.add(navigatorBottom); } }; } /** * Updates the label given the current page and filtering status */ void updateMatched() { matched.setDefaultModel(pagerDelegate.model()); } } public class SelectionModel implements IModel<Boolean> { private static final long serialVersionUID = 7681891298556441330L; int index; public SelectionModel(int index) { this.index = index; } public Boolean getObject() { return selection[index]; } public void setObject(Boolean object) { selection[index] = object.booleanValue(); } public void detach() { // nothing to do } } /** * Sets the table into pageable/non pageable mode. The default is pageable, * in non pageable mode both pagers will be hidden and the number of items per page * is set to the DataView default (Integer.MAX_VALUE) * @param pageable */ public void setPageable(boolean pageable) { if(!pageable) { navigatorTop.setVisible(false); navigatorBottom.setVisible(false); dataView.setItemsPerPage(Integer.MAX_VALUE); selection = new boolean[getDataProvider().getItems().size()]; } else { navigatorTop.setVisible(true); navigatorBottom.setVisible(true); dataView.setItemsPerPage(DEFAULT_ITEMS_PER_PAGE); selection = new boolean[DEFAULT_ITEMS_PER_PAGE]; } } }