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