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.context.WebContext;
38  import info.magnolia.i18nsystem.I18nizer;
39  import info.magnolia.i18nsystem.SimpleTranslator;
40  import info.magnolia.icons.MagnoliaAppIcon;
41  import info.magnolia.icons.MagnoliaIcons;
42  import info.magnolia.periscope.SupplierAwareSearchResult;
43  import info.magnolia.periscope.search.SearchResultSupplier;
44  import info.magnolia.periscope.search.SearchResultSupplierDefinitionRegistry;
45  import info.magnolia.ui.contentapp.configuration.column.renderer.HtmlCleaningRenderer;
46  import info.magnolia.ui.vaadin.grid.IconRenderer;
47  
48  import java.util.ArrayList;
49  import java.util.HashMap;
50  import java.util.List;
51  import java.util.Map;
52  import java.util.Optional;
53  import java.util.function.Consumer;
54  
55  import javax.inject.Provider;
56  
57  import org.apache.commons.lang3.StringUtils;
58  import org.vaadin.extension.gridscroll.GridScrollExtension;
59  
60  import com.vaadin.data.SelectionModel;
61  import com.vaadin.server.Resource;
62  import com.vaadin.ui.CheckBox;
63  import com.vaadin.ui.CustomComponent;
64  import com.vaadin.ui.Grid;
65  import com.vaadin.ui.Grid.SelectionMode;
66  import com.vaadin.ui.HorizontalLayout;
67  import com.vaadin.ui.Label;
68  
69  /**
70   * A {@link Grid} implementation which is specialised for {@linkplain info.magnolia.periscope.search.SearchResult search results}.
71   */
72  class SearchResultsGrid extends CustomComponent {
73  
74      private static final int SUPPLIER_MAX_WIDTH = 380;
75      private static final int COL_MIN_WIDTH = 80;
76  
77      private final List<Consumer<SupplierAwareSearchResult>> resultPickCallbacks = new ArrayList<>();
78      private final Grid<SupplierAwareSearchResult> grid = new Grid<>();
79      private GridScrollExtension<SupplierAwareSearchResult> gridExt = new GridScrollExtension<>(grid);
80      private final SearchResultSupplierDefinitionRegistry resultSupplierRegistry;
81      private final I18nizer i18nizer;
82      private final SimpleTranslator i18n;
83      private List<SupplierAwareSearchResult> searchResults;
84      private int selection = -1;
85      private Consumer<Boolean> scrollingCallback;
86      /**
87       * Track first-in-list result from each supplier, for styling purposes.
88       */
89      private final Map<String, SupplierAwareSearchResult> firstResultsPerSupplier = new HashMap<>();
90      /**
91       * Similar to firsts, track lasts-in-list per supplier for styling.
92       */
93      private final Map<String, SupplierAwareSearchResult> lastResultsPerSupplier = new HashMap<>();
94  
95      private SearchResultSupplier currentAppSupplier;
96      private String currentQuery;
97  
98      SearchResultsGrid(SearchResultSupplierDefinitionRegistry resultSupplierRegistry, I18nizer i18nizer, SimpleTranslator i18n, CheckBox appBiasToggle, Provider<WebContext> contextProvider) {
99          this.resultSupplierRegistry = resultSupplierRegistry;
100         this.i18nizer = i18nizer;
101         this.i18n = i18n;
102         this.setStyleName("search-results-grid");
103 
104         grid.addStyleName("findbar-results");
105         SelectionModel<SupplierAwareSearchResult> selectionModel = grid.setSelectionMode(SelectionMode.SINGLE);
106         // disallow empty selection
107         ((SelectionModel.Single<?>) selectionModel).setDeselectAllowed(false);
108 
109         this.grid.addItemClickListener(event -> {
110             // sometimes throws NPE after double-click without this check, see MGNLUI-5103, MGNLPER-105
111             if (event.getItem() != null) {
112                 this.triggerResult(event.getItem());
113             }
114         });
115         this.grid.addSelectionListener(event -> {
116             if (event.isUserOriginated()) {
117                 this.dropSelection();
118             }
119         });
120 
121         this.buildAppColumn(appBiasToggle)
122                 .setSortable(false)
123                 .setResizable(false)
124                 .setExpandRatio(3)
125                 .setMinimumWidth(COL_MIN_WIDTH)
126                 .setMinimumWidthFromContent(false)
127                 .setMaximumWidth(SUPPLIER_MAX_WIDTH)
128                 .setStyleGenerator(item -> {
129                     String styleName = "supplier";
130                     if (firstResultsPerSupplier.get(item.getSupplierName()) == item) {
131                         styleName += " first";
132                     }
133                     if (lastResultsPerSupplier.get(item.getSupplierName()) == item) {
134                         styleName += " last";
135                     }
136                     return styleName;
137                 });
138 
139         this.grid.addColumn(this::resolveIconResource, new IconRenderer<>())
140                 .setCaption(i18n.translate("findbar.type"))
141                 .setSortable(false)
142                 .setResizable(false)
143                 .setExpandRatio(0)
144                 .setMinimumWidth(COL_MIN_WIDTH)
145                 .setMinimumWidthFromContent(false)
146                 .setStyleGenerator(item -> "type");
147 
148         this.grid.addColumn(this::buildTitleCellHtml, new HtmlCleaningRenderer(contextProvider.get()))
149                 .setCaption(i18n.translate("findbar.title"))
150                 .setSortable(false)
151                 .setResizable(false)
152                 .setExpandRatio(4)
153                 .setMinimumWidthFromContent(false)
154                 .setStyleGenerator(item -> "title-path-and-excerpt");
155 
156         this.grid.addColumn(SupplierAwareSearchResult::getLastModifiedBy)
157                 .setCaption(i18n.translate("findbar.lasteditor"))
158                 .setSortable(false)
159                 .setResizable(false)
160                 .setExpandRatio(2)
161                 .setMinimumWidth(COL_MIN_WIDTH)
162                 .setMinimumWidthFromContent(false)
163                 .setStyleGenerator(item -> "editors");
164 
165         this.grid.addColumn(SupplierAwareSearchResult::getLastModified,
166                 lastModifiedOptional -> lastModifiedOptional
167                         .map(zonedDateTime -> zonedDateTime.toLocalDate().toString())
168                         .orElse(null))
169                 .setCaption(i18n.translate("findbar.lastedited"))
170                 .setSortable(false)
171                 .setResizable(false)
172                 .setExpandRatio(2)
173                 .setMinimumWidth(COL_MIN_WIDTH)
174                 .setMinimumWidthFromContent(false)
175                 .setStyleGenerator(item -> "dates");
176 
177         this.grid.setStyleGenerator(result -> {
178             String styleName = (StringUtils.isBlank(result.getPath()) && StringUtils.isBlank(result.getContent())) ? "" : "has-path-or-excerpt";
179             if (currentAppSupplier != null && currentAppSupplier.getName().equals(result.getSupplierName())) {
180                 styleName += " current-app";
181             }
182             return styleName;
183         });
184 
185         gridExt.addGridScrolledListener(event -> {
186             Integer yPos = gridExt.getLastYPosition();
187             if (scrollingCallback != null) {
188                 scrollingCallback.accept(yPos != 0);
189             }
190         });
191 
192         this.grid.setWidth(100, Unit.PERCENTAGE);
193 
194         setSizeFull();
195         setCompositionRoot(grid);
196     }
197 
198     private void triggerResult(SupplierAwareSearchResult searchResult) {
199         resultPickCallbacks.forEach(callback -> callback.accept(searchResult));
200     }
201 
202     private Grid.Column<SupplierAwareSearchResult, ?> buildAppColumn(CheckBox appBiasToggle) {
203         Grid.Column<SupplierAwareSearchResult, ?> appColumn = this.grid.addColumn(result -> Optional.ofNullable(getI18nizedSupplierLabel(result))
204                 .filter(StringUtils::isNotEmpty)
205                 .orElse(result.getSupplierName()));
206 
207         HorizontalLayout appBiasToggleLayout = new HorizontalLayout();
208         appBiasToggleLayout.setStyleName("app-bias-toggle");
209         appBiasToggleLayout.setVisible(false);
210         appBiasToggleLayout.addComponent(new Label(i18n.translate("findbar.filter.appbias")));
211         appBiasToggleLayout.addComponent(appBiasToggle);
212 
213         HorizontalLayout columnLayout = new HorizontalLayout();
214         columnLayout.setStyleName("v-grid-column-header-content");
215         columnLayout.addComponent(new Label(i18n.translate("findbar.app")));
216         columnLayout.addComponent(appBiasToggleLayout);
217 
218         this.grid.getDefaultHeaderRow().getCell(appColumn).setComponent(columnLayout);
219 
220         return appColumn;
221     }
222 
223 
224     private String getI18nizedSupplierLabel(SupplierAwareSearchResult result) {
225         return i18nizer.decorate(resultSupplierRegistry.getProvider(result.getSupplierName()).get()).getLabel();
226     }
227 
228     private String buildTitleCellHtml(SupplierAwareSearchResult result) {
229         String html = String.format("<div class='title'>%s</div>", HtmlHighlighter.highlight(result.getTitle(), currentQuery));
230 
231         String excerpt = result.getContent();
232         String path = result.getPath();
233         boolean renderPath = StringUtils.isNotBlank(path) && (StringUtils.isBlank(excerpt) || !excerpt.equals(path));
234         if (renderPath) {
235             html += String.format("<div class='path'>%s</div>", path);
236         }
237 
238         if (StringUtils.isNotBlank(excerpt)) {
239             String highlightedExcerpt = HtmlHighlighter.highlight(ExcerptProvider.getExcerpt(currentQuery, excerpt), currentQuery);
240             html += String.format("<div class='excerpt'>%s</div>", highlightedExcerpt);
241         }
242         return html;
243     }
244 
245     void clear() {
246         this.grid.setItems();
247     }
248 
249     void addSelectionCallback(Consumer<SupplierAwareSearchResult> pickCallback) {
250         this.resultPickCallbacks.add(pickCallback);
251     }
252 
253     void setScrollingCallback(Consumer<Boolean> callback) {
254         this.scrollingCallback = callback;
255     }
256 
257     private Resource resolveIconResource(SupplierAwareSearchResult result) {
258         final String iconName = result.getType();
259 
260         if (AppSearchResultSupplier.APPS_SUPPLIER_NAME.equals(result.getSupplierName())) {
261             return MagnoliaAppIcon.forName(iconName)
262                     .or(MagnoliaIcons::forCssClass)
263                     .or(MagnoliaIcons.APP)
264                     .resolve()
265                     .orElse(null);
266         }
267 
268         return MagnoliaIcons.forCssClass(iconName).orElse(MagnoliaIcons.APP);
269     }
270 
271     void setSearchResults(List<SupplierAwareSearchResult> searchResults, String query) {
272         this.currentQuery = query;
273         this.searchResults = searchResults;
274         this.selection = -1;
275         populateFirstAndLasts(searchResults);
276         this.grid.setItems(searchResults);
277     }
278 
279     private void populateFirstAndLasts(List<SupplierAwareSearchResult> results) {
280         this.firstResultsPerSupplier.clear();
281         this.lastResultsPerSupplier.clear();
282         results.forEach(result -> {
283             final String supplierName = result.getSupplierName();
284             if (!this.firstResultsPerSupplier.containsKey(supplierName)) {
285                 this.firstResultsPerSupplier.put(supplierName, result);
286             }
287             this.lastResultsPerSupplier.put(supplierName, result);
288         });
289     }
290 
291     /**
292      * Trigger (open, execute) the currently selected result.
293      */
294     void triggerSelected() {
295         // prevent focus from staying somewhere else (like at the find bar input)
296         grid.focus();
297 
298         Optional<SupplierAwareSearchResult> selected = grid.getSelectedItems().stream().findFirst();
299         if (selected.isPresent()) {
300             triggerResult(selected.get());
301         } else {
302             // if there's no selection, simply use top result
303             searchResults.stream().findFirst().ifPresent(this::triggerResult);
304         }
305     }
306 
307     void selectFirstResult() {
308         this.selection = -1;
309         selectNextResult();
310     }
311 
312     void selectNextResult() {
313         if (selection >= searchResults.size() - 1) {
314             return;
315         }
316         selection++;
317         grid.select(searchResults.get(selection));
318         grid.scrollTo(selection);
319     }
320 
321     void selectPreviousResult() {
322         if (selection <= 0) {
323             return;
324         }
325         selection--;
326         grid.select(searchResults.get(selection));
327         grid.scrollTo(selection);
328     }
329 
330     void dropSelection() {
331         grid.deselectAll();
332         selection = -1;
333     }
334 
335     boolean hasSelection() {
336         return selection >= 0;
337     }
338 
339     void setCurrentAppSupplier(SearchResultSupplier currentAppSupplier) {
340         this.currentAppSupplier = currentAppSupplier;
341     }
342 }