View Javadoc
1   /**
2    * This file Copyright (c) 2018 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.admincentral.findbar;
35  
36  import info.magnolia.admincentral.findbar.search.apps.AppSearchResultSupplier;
37  import info.magnolia.i18nsystem.SimpleTranslator;
38  import info.magnolia.icons.MagnoliaAppIcon;
39  import info.magnolia.icons.MagnoliaIcons;
40  import info.magnolia.periscope.SupplierAwareSearchResult;
41  import info.magnolia.periscope.search.SearchResultSupplier;
42  import info.magnolia.ui.vaadin.grid.IconRenderer;
43  
44  import java.util.ArrayList;
45  import java.util.HashMap;
46  import java.util.List;
47  import java.util.Map;
48  import java.util.Optional;
49  import java.util.function.Consumer;
50  
51  import org.apache.commons.lang3.StringUtils;
52  import org.vaadin.extension.gridscroll.GridScrollExtension;
53  
54  import com.vaadin.data.SelectionModel;
55  import com.vaadin.server.Resource;
56  import com.vaadin.ui.CheckBox;
57  import com.vaadin.ui.CustomComponent;
58  import com.vaadin.ui.Grid;
59  import com.vaadin.ui.Grid.SelectionMode;
60  import com.vaadin.ui.HorizontalLayout;
61  import com.vaadin.ui.Label;
62  import com.vaadin.ui.renderers.HtmlRenderer;
63  
64  /**
65   * A {@link Grid} implementation which is specialised for {@linkplain info.magnolia.periscope.search.SearchResult search results}.
66   */
67  class SearchResultsGrid extends CustomComponent {
68  
69      private static final int SUPPLIER_MAX_WIDTH = 380;
70      private static final int COL_MIN_WIDTH = 80;
71  
72      private final List<Consumer<SupplierAwareSearchResult>> resultPickCallbacks = new ArrayList<>();
73      private final Grid<SupplierAwareSearchResult> grid = new Grid<>();
74      private GridScrollExtension<SupplierAwareSearchResult> gridExt = new GridScrollExtension<>(grid);
75      private final SimpleTranslator i18n;
76      private List<SupplierAwareSearchResult> searchResults;
77      private int selection = -1;
78      private Consumer<Boolean> scrollingCallback;
79      /**
80       * Track first-in-list result from each supplier, for styling purposes.
81       */
82      private final Map<String, SupplierAwareSearchResult> firstResultsPerSupplier = new HashMap<>();
83      /**
84       * Similar to firsts, track lasts-in-list per supplier for styling.
85       */
86      private final Map<String, SupplierAwareSearchResult> lastResultsPerSupplier = new HashMap<>();
87  
88      private SearchResultSupplier currentAppSupplier;
89      private String currentQuery;
90  
91      SearchResultsGrid(SimpleTranslator i18n, CheckBox appBiasToggle) {
92          this.i18n = i18n;
93          this.setStyleName("search-results-grid");
94  
95          grid.addStyleName("findbar-results");
96          SelectionModel<SupplierAwareSearchResult> selectionModel = grid.setSelectionMode(SelectionMode.SINGLE);
97          // disallow empty selection
98          ((SelectionModel.Single<?>) selectionModel).setDeselectAllowed(false);
99  
100         this.grid.addItemClickListener(event -> {
101             // sometimes throws NPE after double-click without this check, see MGNLUI-5103, MGNLPER-105
102             if (event.getItem() != null) {
103                 this.triggerResult(event.getItem());
104             }
105         });
106         this.grid.addSelectionListener(event -> {
107             if (event.isUserOriginated()) {
108                 this.dropSelection();
109             }
110         });
111 
112         this.buildAppColumn(appBiasToggle)
113                 .setSortable(false)
114                 .setResizable(false)
115                 .setExpandRatio(3)
116                 .setMinimumWidth(COL_MIN_WIDTH)
117                 .setMinimumWidthFromContent(false)
118                 .setMaximumWidth(SUPPLIER_MAX_WIDTH)
119                 .setStyleGenerator(item -> {
120                     String styleName = "supplier";
121                     if (firstResultsPerSupplier.get(item.getSupplierName()) == item) {
122                         styleName += " first";
123                     }
124                     if (lastResultsPerSupplier.get(item.getSupplierName()) == item) {
125                         styleName += " last";
126                     }
127                     return styleName;
128                 });
129 
130         this.grid.addColumn(this::resolveIconResource, new IconRenderer<>())
131                 .setCaption(i18n.translate("findbar.type"))
132                 .setSortable(false)
133                 .setResizable(false)
134                 .setExpandRatio(0)
135                 .setMinimumWidth(COL_MIN_WIDTH)
136                 .setMinimumWidthFromContent(false)
137                 .setStyleGenerator(item -> "type");
138 
139         this.grid.addColumn(this::buildTitleCellHtml, new HtmlRenderer())
140                 .setCaption(i18n.translate("findbar.title"))
141                 .setSortable(false)
142                 .setResizable(false)
143                 .setExpandRatio(4)
144                 .setMinimumWidthFromContent(false)
145                 .setStyleGenerator(item -> "title-path-and-excerpt");
146 
147         this.grid.addColumn(SupplierAwareSearchResult::getLastModifiedBy)
148                 .setCaption(i18n.translate("findbar.lasteditor"))
149                 .setSortable(false)
150                 .setResizable(false)
151                 .setExpandRatio(2)
152                 .setMinimumWidth(COL_MIN_WIDTH)
153                 .setMinimumWidthFromContent(false)
154                 .setStyleGenerator(item -> "editors");
155 
156         this.grid.addColumn(SupplierAwareSearchResult::getLastModified,
157                 lastModifiedOptional -> lastModifiedOptional
158                         .map(zonedDateTime -> zonedDateTime.toLocalDate().toString())
159                         .orElse(null))
160                 .setCaption(i18n.translate("findbar.lastedited"))
161                 .setSortable(false)
162                 .setResizable(false)
163                 .setExpandRatio(2)
164                 .setMinimumWidth(COL_MIN_WIDTH)
165                 .setMinimumWidthFromContent(false)
166                 .setStyleGenerator(item -> "dates");
167 
168         this.grid.setStyleGenerator(result -> {
169             String styleName = (StringUtils.isBlank(result.getPath()) && StringUtils.isBlank(result.getContent())) ? "" : "has-path-or-excerpt";
170             if (currentAppSupplier != null && currentAppSupplier.getName().equals(result.getSupplierName())) {
171                 styleName += " current-app";
172             }
173             return styleName;
174         });
175 
176         gridExt.addGridScrolledListener(event -> {
177             Integer yPos = gridExt.getLastYPosition();
178             if (scrollingCallback != null) {
179                 scrollingCallback.accept(yPos != 0);
180             }
181         });
182 
183         this.grid.setWidth(100, Unit.PERCENTAGE);
184 
185         setSizeFull();
186         setCompositionRoot(grid);
187     }
188 
189     private void triggerResult(SupplierAwareSearchResult searchResult) {
190         resultPickCallbacks.forEach(callback -> callback.accept(searchResult));
191     }
192 
193     private Grid.Column<SupplierAwareSearchResult, ?> buildAppColumn(CheckBox appBiasToggle) {
194         Grid.Column<SupplierAwareSearchResult, ?> appColumn = this.grid.addColumn(SupplierAwareSearchResult::getSupplierName);
195 
196         HorizontalLayout appBiasToggleLayout = new HorizontalLayout();
197         appBiasToggleLayout.setStyleName("app-bias-toggle");
198         appBiasToggleLayout.setVisible(false);
199         appBiasToggleLayout.addComponent(new Label(i18n.translate("findbar.filter.appbias")));
200         appBiasToggleLayout.addComponent(appBiasToggle);
201 
202         HorizontalLayout columnLayout = new HorizontalLayout();
203         columnLayout.setStyleName("v-grid-column-header-content");
204         columnLayout.addComponent(new Label(i18n.translate("findbar.app")));
205         columnLayout.addComponent(appBiasToggleLayout);
206 
207         this.grid.getDefaultHeaderRow().getCell(appColumn).setComponent(columnLayout);
208 
209         return appColumn;
210     }
211 
212     private String buildTitleCellHtml(SupplierAwareSearchResult result) {
213         String html = String.format("<div class='title'>%s</div>", HtmlHighlighter.highlight(result.getTitle(), currentQuery));
214 
215         String excerpt = result.getContent();
216         String path = result.getPath();
217         boolean renderPath = StringUtils.isNotBlank(path) && (StringUtils.isBlank(excerpt) || !excerpt.equals(path));
218         if (renderPath) {
219             html += String.format("<div class='path'>%s</div>", path);
220         }
221 
222         if (StringUtils.isNotBlank(excerpt)) {
223             String highlightedExcerpt = HtmlHighlighter.highlight(ExcerptProvider.getExcerpt(currentQuery, excerpt), currentQuery);
224             html += String.format("<div class='excerpt'>%s</div>", highlightedExcerpt);
225         }
226         return html;
227     }
228 
229     void clear() {
230         this.grid.setItems();
231     }
232 
233     void addSelectionCallback(Consumer<SupplierAwareSearchResult> pickCallback) {
234         this.resultPickCallbacks.add(pickCallback);
235     }
236 
237     void setScrollingCallback(Consumer<Boolean> callback) {
238         this.scrollingCallback = callback;
239     }
240 
241     private Resource resolveIconResource(SupplierAwareSearchResult result) {
242         final String iconName = result.getType();
243 
244         if (AppSearchResultSupplier.APPS_SUPPLIER_NAME.equals(result.getSupplierName())) {
245             return MagnoliaAppIcon.forName(iconName)
246                     .or(MagnoliaIcons::forCssClass)
247                     .or(MagnoliaIcons.APP)
248                     .resolve()
249                     .orElse(null);
250         }
251 
252         return MagnoliaIcons.forCssClass(iconName).orElse(MagnoliaIcons.APP);
253     }
254 
255     void setSearchResults(List<SupplierAwareSearchResult> searchResults, String query) {
256         this.currentQuery = query;
257         this.searchResults = searchResults;
258         this.selection = -1;
259         populateFirstAndLasts(searchResults);
260         this.grid.setItems(searchResults);
261     }
262 
263     private void populateFirstAndLasts(List<SupplierAwareSearchResult> results) {
264         this.firstResultsPerSupplier.clear();
265         this.lastResultsPerSupplier.clear();
266         results.forEach(result -> {
267             final String supplierName = result.getSupplierName();
268             if (!this.firstResultsPerSupplier.containsKey(supplierName)) {
269                 this.firstResultsPerSupplier.put(supplierName, result);
270             }
271             this.lastResultsPerSupplier.put(supplierName, result);
272         });
273     }
274 
275     /**
276      * Trigger (open, execute) the currently selected result.
277      */
278     void triggerSelected() {
279         // prevent focus from staying somewhere else (like at the find bar input)
280         grid.focus();
281 
282         Optional<SupplierAwareSearchResult> selected = grid.getSelectedItems().stream().findFirst();
283         if (selected.isPresent()) {
284             triggerResult(selected.get());
285         } else {
286             // if there's no selection, simply use top result
287             searchResults.stream().findFirst().ifPresent(this::triggerResult);
288         }
289     }
290 
291     void selectFirstResult() {
292         this.selection = -1;
293         selectNextResult();
294     }
295 
296     void selectNextResult() {
297         if (selection >= searchResults.size() - 1) {
298             return;
299         }
300         selection++;
301         grid.select(searchResults.get(selection));
302         grid.scrollTo(selection);
303     }
304 
305     void selectPreviousResult() {
306         if (selection <= 0) {
307             return;
308         }
309         selection--;
310         grid.select(searchResults.get(selection));
311         grid.scrollTo(selection);
312     }
313 
314     void dropSelection() {
315         grid.deselectAll();
316         selection = -1;
317     }
318 
319     boolean hasSelection() {
320         return selection >= 0;
321     }
322 
323     void setCurrentAppSupplier(SearchResultSupplier currentAppSupplier) {
324         this.currentAppSupplier = currentAppSupplier;
325     }
326 }