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 static info.magnolia.admincentral.ViewportLayout.View.*;
37  import static java.util.stream.Collectors.*;
38  
39  import info.magnolia.admincentral.ViewportLayout;
40  import info.magnolia.admincentral.findbar.layout.FilterLayout;
41  import info.magnolia.admincentral.findbar.search.ResultCollector;
42  import info.magnolia.admincentral.findbar.search.SupplierSorter;
43  import info.magnolia.cms.security.SecuritySupport;
44  import info.magnolia.cms.security.User;
45  import info.magnolia.context.Context;
46  import info.magnolia.event.EventBus;
47  import info.magnolia.i18nsystem.SimpleTranslator;
48  import info.magnolia.icons.MagnoliaIcons;
49  import info.magnolia.periscope.Periscope;
50  import info.magnolia.periscope.SupplierAwareSearchResult;
51  import info.magnolia.periscope.ai.speech.SpeechRecognizer;
52  import info.magnolia.periscope.operation.OperationRequest;
53  import info.magnolia.periscope.operation.OperationResult;
54  import info.magnolia.periscope.search.SearchQuery;
55  import info.magnolia.periscope.search.SearchResultSupplier;
56  import info.magnolia.periscope.search.jcr.JcrSearchResultSupplier;
57  import info.magnolia.periscope.tag.EmptyPeriscopeTagsProvider;
58  import info.magnolia.periscope.tag.PeriscopeTagsProvider;
59  import info.magnolia.ui.api.app.AppController;
60  import info.magnolia.ui.api.app.AppLifecycleEvent;
61  import info.magnolia.ui.api.app.AppLifecycleEventHandler;
62  import info.magnolia.ui.api.app.launcherlayout.AppLauncherGroupEntry;
63  import info.magnolia.ui.api.app.launcherlayout.AppLauncherLayout;
64  import info.magnolia.ui.api.app.launcherlayout.AppLauncherLayoutManager;
65  import info.magnolia.ui.api.event.AdmincentralEventBus;
66  import info.magnolia.ui.api.location.DefaultLocation;
67  import info.magnolia.ui.api.location.Location;
68  import info.magnolia.ui.api.location.LocationController;
69  import info.magnolia.ui.api.view.View;
70  import info.magnolia.ui.theme.ResurfaceTheme;
71  import info.magnolia.ui.vaadin.applauncher.AppLauncher;
72  import info.magnolia.util.OptionalConsumer;
73  
74  import java.time.LocalTime;
75  import java.time.ZoneId;
76  import java.util.Arrays;
77  import java.util.Collections;
78  import java.util.HashSet;
79  import java.util.List;
80  import java.util.Optional;
81  import java.util.Set;
82  import java.util.concurrent.atomic.AtomicBoolean;
83  import java.util.stream.Stream;
84  
85  import javax.inject.Inject;
86  import javax.inject.Named;
87  import javax.inject.Provider;
88  
89  import org.apache.commons.collections4.CollectionUtils;
90  import org.apache.commons.lang3.StringUtils;
91  import org.slf4j.Logger;
92  import org.slf4j.LoggerFactory;
93  import org.vaadin.addons.ComboBoxMultiselect;
94  
95  import com.google.common.collect.Sets;
96  import com.vaadin.addon.daterangefield.DateRangeField;
97  import com.vaadin.event.ShortcutAction;
98  import com.vaadin.event.ShortcutListener;
99  import com.vaadin.server.Page;
100 import com.vaadin.server.Sizeable;
101 import com.vaadin.ui.AbsoluteLayout;
102 import com.vaadin.ui.AbstractOrderedLayout;
103 import com.vaadin.ui.Button;
104 import com.vaadin.ui.CheckBox;
105 import com.vaadin.ui.Component;
106 import com.vaadin.ui.Label;
107 import com.vaadin.ui.Layout;
108 import com.vaadin.ui.UI;
109 import com.vaadin.ui.themes.ValoTheme;
110 
111 /**
112  * The {@linkplain FindBar Find bar} brings federated search for content & apps throughout the whole Magnolia instance and repository.
113  *
114  * <p>This is powered by the {@link Periscope Periscope} search engine, which offers pluggable
115  * {@linkplain SearchResultSupplier Search result suppliers}.
116  */
117 public class FindBar implements View {
118 
119     private static final Logger log = LoggerFactory.getLogger(FindBar.class);
120 
121     private static final int DEFAULT_POLL_INTERVAL = -1;
122 
123     private final FindBarLayout findBarLayout = new FindBarLayout();
124 
125     private final AtomicBoolean isRecording = new AtomicBoolean(false);
126 
127     private FindBarTextField findBarTextField;
128     private Button findBarButton;
129     private ComboBoxMultiselect<String> supplierField;
130     private ComboBoxMultiselect<String> tagField;
131     private ComboBoxMultiselect<String> editorField;
132     private CheckBox appBiasToggle;
133     private DateRangeField dateRangeField;
134     private FilterLayout filterLayout;
135     SearchResultsGrid resultsGrid;
136     private Set<SearchResultSupplier> availableResultSuppliers;
137 
138     private final Periscope periscope;
139     private final FindBarConfigurationProvider configProvider;
140     private final PeriscopeTagsProvider periscopeTagsProvider;
141     private final SecuritySupport securitySupport;
142     private final SimpleTranslator i18n;
143     private final EventBus eventBus;
144     private final User currentUser;
145 
146     private final LocationController locationController;
147     private final AppLauncher appLauncher;
148     private final AppLauncherLayoutManager appLauncherLayoutManager;
149     private final AppController appController;
150     private final ResultCollector resultCollector;
151     private final SupplierSorter sorter;
152     private final SpeechRecognizer speechRecognizer;
153 
154     private SearchQuery latestQuery;
155     private String latestApp;
156     private String currentApp;
157 
158     private final ViewportLayout viewportLayout;
159 
160     @Inject
161     FindBar(Periscope periscope, SecuritySupport securitySupport, SpeechRecognizer speechRecognizer,
162             FindBarConfigurationProvider configProvider,
163             PeriscopeTagsProvider periscopeTagsProvider, @Named(AdmincentralEventBus.NAME) EventBus eventBus,
164             SimpleTranslator i18n, Provider<Context> context, LocationController locationController,
165             AppLauncherLayoutManager appLauncherLayoutManager, AppController appController,
166             ViewportLayout viewportLayout) {
167 
168         this.periscope = periscope;
169         this.configProvider = configProvider;
170         this.periscopeTagsProvider = periscopeTagsProvider;
171         this.securitySupport = securitySupport;
172         this.i18n = i18n;
173         this.eventBus = eventBus;
174         this.currentUser = context.get().getUser();
175         this.locationController = locationController;
176         this.appLauncherLayoutManager = appLauncherLayoutManager;
177         this.appController = appController;
178         this.appLauncher = new AppLauncher(this::openApp);
179         this.resultCollector = new ResultCollector(periscope::search, this::updateResults);
180         this.speechRecognizer = speechRecognizer;
181 
182         this.sorter = new SupplierSorter(Optional.ofNullable(configProvider.get().getSupplierOrder()).orElse(Collections.emptyList()));
183         this.viewportLayout = viewportLayout;
184         initUi();
185     }
186 
187     private synchronized void updateResults(SearchQuery searchQuery, List<SupplierAwareSearchResult> results) {
188         findUi(this.asVaadinComponent()).ifPresent(ui -> ui.access(() -> {
189             filterLayout.updateTitleLabel(StringUtils.isEmpty(searchQuery.getQuery()), results.size());
190             resultsGrid.setSearchResults(results, searchQuery.getQuery());
191         }));
192     }
193 
194     private Optional<UI> findUi(Component component) {
195         UI current = component.getUI();
196         if (current == null) {
197             throw new IllegalStateException("Top-level UI component could not be found");
198         }
199         return current.isAttached() ? Optional.of(current) : Optional.empty();
200     }
201 
202     private void launchCommand() {
203         OptionalConsumer<OperationRequest> operationRequestConsumer = OptionalConsumer.of(findBarTextField.getCommand());
204         operationRequestConsumer.ifPresent(command -> {
205             OperationResult operationResult = periscope.executeOperation(command);
206             if (operationResult.isSucceeded()) {
207                 findBarTextField.clearCommand();
208                 findBarButton.setIcon(MagnoliaIcons.SEARCH);
209             }
210         }).ifNotPresent(() -> {
211             if (resultsGrid.hasSelection()) {
212                 resultsGrid.triggerSelected();
213                 // blur text field (remove focus)
214                 Page.getCurrent().getJavaScript().execute("jQuery('input.findbar').blur()");
215             }
216         });
217     }
218 
219     void initUi() {
220         Set<String> configuredSuppliers = configProvider.get().getSuppliers();
221         availableResultSuppliers = CollectionUtils.isEmpty(configuredSuppliers) ? new HashSet<>(periscope.getResultSuppliers())
222                 : periscope.getResultSuppliers().stream().filter(s -> configuredSuppliers.contains(s.getName())).collect(toSet());
223 
224         AppLauncherLayout userLayout = this.appLauncherLayoutManager.getLayoutForUser(currentUser);
225         List<SearchResultSupplier> inaccessibleAppSuppliers = availableResultSuppliers.stream()
226                 .filter(supplier -> isForbiddenApp(supplier, userLayout))
227                 .collect(toList());
228         availableResultSuppliers.removeAll(inaccessibleAppSuppliers);
229 
230         Set<String> availableSupplierNames = availableResultSuppliers.stream()
231                 .map(SearchResultSupplier::getName)
232                 .collect(toSet());
233 
234         if (CollectionUtils.isNotEmpty(configuredSuppliers)) {
235             Sets.difference(configuredSuppliers, availableSupplierNames).forEach(supplier -> log.warn("Search result supplier enabled in config, but not found: {}", supplier));
236         }
237 
238         this.supplierField = new ComboBoxMultiselect<>(i18n.translate("findbar.type"));
239         this.supplierField.setItems(availableSupplierNames);
240 
241         this.tagField = new ComboBoxMultiselect<>(i18n.translate("findbar.tags"));
242         this.tagField.setItems(periscopeTagsProvider.getAllTags());
243 
244         this.editorField = new ComboBoxMultiselect<>(i18n.translate("findbar.lasteditor"));
245         this.editorField.setDataProvider(new LastEditorDataProvider(this::resolveUsers));
246         this.dateRangeField = new DateRangeField(i18n.translate("findbar.lastedited"));
247 
248         this.appBiasToggle = new CheckBox();
249         this.appBiasToggle.setValue(true);
250 
251         this.resultsGrid = new SearchResultsGrid(i18n, appBiasToggle);
252         this.resultsGrid.addSelectionCallback(searchResult -> this.periscope.resultPicked(buildSearchQuery(), searchResult));
253 
254         final Layout resultsLayout = viewportLayout.getFindBarResultsLayout();
255         this.resultsGrid.setScrollingCallback(scrolled -> {
256             if (scrolled) {
257                 resultsLayout.addStyleName("grid-scrolled");
258             } else {
259                 resultsLayout.removeStyleName("grid-scrolled");
260             }
261         });
262 
263         this.filterLayout = periscopeTagsProvider instanceof EmptyPeriscopeTagsProvider ?
264                 new FilterLayout(dateRangeField, i18n, Arrays.asList(supplierField, editorField)) :
265                 new FilterLayout(dateRangeField, i18n, Arrays.asList(supplierField, tagField, editorField));
266         resultsLayout.addComponents(filterLayout, resultsGrid);
267         resultsGrid.setSizeFull();
268         resultsLayout.addStyleName("filter-and-results");
269 
270         userLayout.getGroups().forEach(group -> {
271             appLauncher.addAppGroup(group.getName(), group.getLabel(), group.getColor(), true, group.isClientGroup());
272             group.getApps().stream().map(AppLauncherGroupEntry::getAppDescriptor).forEach(descriptor -> {
273                 appLauncher.addAppTile(descriptor.getName(), descriptor.getLabel(), descriptor.getIcon(), group.getName());
274                 appLauncher.setAppActive(descriptor.getName(), appController.isAppStarted(descriptor.getName()));
275             });
276         });
277 
278         final Label title = new Label("Apps");
279         title.addStyleName(ValoTheme.LABEL_H1);
280 
281         AbstractOrderedLayout appLauncherLayout = viewportLayout.getAppLauncherLayout();
282         appLauncherLayout.setSizeFull();
283         appLauncher.setWidth(100, Sizeable.Unit.PERCENTAGE);
284         appLauncherLayout.addComponents(title, appLauncher);
285         appLauncherLayout.setExpandRatio(appLauncher, 1f);
286         appLauncherLayout.addStyleName("v-app-launcher");
287 
288         appController.setViewport(view -> {
289             final Location currentAppLocation = appController.getCurrentAppLocation();
290             Component content = view == null ? null : view.asVaadinComponent();
291             Optional.ofNullable(viewportLayout.getActiveAppView(currentAppLocation)).ifPresent(v -> v.setContent(content));
292         });
293 
294         this.supplierField.addValueChangeListener(event -> {
295             updateBiasToggleVisibility();
296             search(true);
297         });
298         this.tagField.addValueChangeListener(event -> search(true));
299         this.editorField.addValueChangeListener(event -> search(true));
300         this.dateRangeField.addValueChangeListener(event -> search(true));
301         this.appBiasToggle.addValueChangeListener(event -> search(true));
302 
303         findBarTextField = new FindBarTextField(i18n, this::search);
304         findBarButton = new Button(MagnoliaIcons.SEARCH);
305         findBarButton.addStyleNames(ResurfaceTheme.BUTTON_ICON, "clear");
306         findBarButton.addClickListener(event -> {
307             viewportLayout.toggleFindBar();
308             if (viewportLayout.getActiveView() == FIND_BAR) {
309                 findBarButton.addStyleName("active");
310                 findBarTextField.focus();
311             } else {
312                 findBarButton.removeStyleName("active");
313             }
314         });
315 
316         Button clearTextButton = new Button(MagnoliaIcons.DELETE_SEARCH);
317         clearTextButton.addStyleNames(ResurfaceTheme.BUTTON_ICON, "clear");
318         clearTextButton.setVisible(false);
319         clearTextButton.addClickListener(event -> {
320             findBarTextField.clear();
321             findBarTextField.focus();
322             clearTextButton.setVisible(false);
323         });
324 
325         Button voiceButton = new Button(MagnoliaIcons.MICROPHONE);
326         voiceButton.addStyleNames(ResurfaceTheme.BUTTON_ICON, "voice");
327 
328         // App launcher
329         Button appLauncherButton = new Button(MagnoliaIcons.APP_LAUNCHER);
330         appLauncherButton.addStyleNames(ResurfaceTheme.BUTTON_ICON, "appLauncher");
331         appLauncherButton.addClickListener(event -> {
332             findBarButton.removeStyleName("active");
333             viewportLayout.toggleAppLauncher();
334             appLauncherButton.setStyleName("active", viewportLayout.getActiveView() == APP_LAUNCHER);
335         });
336         appLauncherButton.addBlurListener(event -> appLauncherButton.removeStyleName("active"));
337 
338         this.findBarTextField.addFocusListener(event -> {
339             viewportLayout.showFindBar();
340             findBarTextField.setFocusStatus(true);
341             findBarButton.addStyleName("active");
342             appLauncherButton.removeStyleName("active");
343             if (StringUtils.isNotEmpty(findBarTextField.getValue().trim())) {
344                 resultsGrid.selectFirstResult();
345             }
346             findBarTextField.getCommand().ifPresent(command -> {
347                 findBarTextField.getCommandHint().setVisible(true);
348                 findBarButton.setIcon(MagnoliaIcons.ENTER_ARROW);
349                 findBarTextField.focus();
350             });
351         });
352         findBarTextField.addBlurListener(event -> {
353             findBarTextField.setFocusStatus(false);
354             findBarButton.removeStyleName("active");
355             findBarTextField.getCommandHint().setVisible(false);
356             findBarButton.setIcon(MagnoliaIcons.SEARCH);
357         });
358 
359         final ShortcutListener enterListener = createShortcutListener("Enter", ShortcutAction.KeyCode.ENTER, new int[]{}, this::launchCommand);
360         findBarTextField.addShortcutListener(enterListener);
361         final ShortcutListener downListener = createShortcutListener("Arrow down", ShortcutAction.KeyCode.ARROW_DOWN, new int[]{}, () -> resultsGrid.selectNextResult());
362         findBarTextField.addShortcutListener(downListener);
363         final ShortcutListener upListener = createShortcutListener("Arrow up", ShortcutAction.KeyCode.ARROW_UP, new int[]{}, () -> resultsGrid.selectPreviousResult());
364         findBarTextField.addShortcutListener(upListener);
365 
366         this.findBarTextField.addValueChangeListener(event -> {
367             if (StringUtils.isBlank(event.getValue())) {
368                 clearTextButton.setVisible(false);
369             } else {
370                 clearTextButton.setVisible(true);
371             }
372             search(true);
373         });
374 
375         // setting absolution positions below is not equivalent to putting them in CSS; they get applied to a wrapping slot
376         findBarLayout.addComponent(findBarTextField);
377         findBarLayout.addComponent(findBarButton, "top: 4px; left: 8px;");
378         findBarLayout.addComponent(findBarTextField.getCommandHint());
379         findBarLayout.addComponent(clearTextButton, "top: 6px; right: 40px;");
380         findBarLayout.addComponent(voiceButton, "top: 6px; right: 40px;");
381         findBarLayout.addComponent(appLauncherButton, "top: 6px; right: 8px;");
382         findBarLayout.addStyleNames("findbar");
383         // clicking anywhere on findbar (except buttons) should give focus to the text field
384         findBarLayout.addLayoutClickListener(event -> findBarTextField.focus());
385 
386         initBrowserSpeechRecognition(voiceButton, () -> {
387             voiceButton.setVisible(true);
388             // push clear-text button further away
389             AbsoluteLayout.ComponentPosition clearPosition = findBarLayout.getPosition(clearTextButton);
390             clearPosition.setCSSString("top: 6px; right: 72px;");
391         });
392 
393         eventBus.addHandler(AppLifecycleEvent.class, new AppLifecycleEventHandler.Adapter() {
394 
395             @Override
396             public void onAppFocused(AppLifecycleEvent event) {
397                 log.debug("#onAppFocused: {}", event.getAppDescriptor().getName());
398                 updateCurrentApp(event.getAppDescriptor().getName());
399                 viewportLayout.showApp(event.getAppDescriptor().getName());
400             }
401 
402             @Override
403             public void onAppStopped(AppLifecycleEvent event) {
404                 log.debug("#onAppStopped: {}", event.getAppDescriptor().getName());
405                 updateCurrentApp(null);
406                 supplierField.deselectAll();
407                 appLauncher.setAppActive(event.getAppDescriptor().getName(), false);
408                 filterLayout.updateGlobalTokenLayout();
409                 // We do yet another search to fetch the latest suggestions.
410                 search();
411                 viewportLayout.hideApp(event.getAppDescriptor().getName());
412             }
413 
414             @Override
415             public void onAppStarted(AppLifecycleEvent event) {
416                 appLauncher.setAppActive(event.getAppDescriptor().getName(), true);
417                 log.debug("#onAppStarted: {}", event.getAppDescriptor().getName());
418                 updateCurrentApp(event.getAppDescriptor().getName());
419                 viewportLayout.showApp(event.getAppDescriptor().getName());
420             }
421 
422             private void updateCurrentApp(String appName) {
423                 latestApp = currentApp;
424                 currentApp = appName;
425                 updateBiasToggleVisibility();
426                 if (!StringUtils.equals(currentApp, latestApp)) {
427                     findBarTextField.clear();
428                     search();
429                 }
430             }
431         });
432     }
433 
434     private ShortcutListener createShortcutListener(String caption, int keyCode, int[] modifiers, Runnable action) {
435         return new ShortcutListener(caption, keyCode, modifiers) {
436             @Override
437             public void handleAction(Object sender, Object target) {
438                 action.run();
439             }
440         };
441     }
442 
443     private void updateBiasToggleVisibility() {
444         if (!supplierField.isEmpty()) {
445             Set<String> selected = supplierField.getValue();
446             appBiasToggle.getParent().setVisible(selected.size() > 1 && selected.contains(currentApp));
447             return;
448         }
449 
450         boolean currentAppHasSupplier = availableResultSuppliers.stream().anyMatch(s -> s.getName().equals(currentApp));
451         appBiasToggle.getParent().setVisible(currentAppHasSupplier);
452     }
453 
454     private void openApp(String appName) {
455         locationController.goTo(new DefaultLocation(Location.LOCATION_TYPE_APP, appName));
456         viewportLayout.showApp(appName);
457     }
458 
459     private void initBrowserSpeechRecognition(Button voiceButton, SpeechRecognizer.Listener supportedListener) {
460         speechRecognizer.attachTo(findBarLayout);
461 
462         // invisible until front-end declares it supported, only in chrome as of right now
463         voiceButton.setVisible(false);
464         speechRecognizer.setSupportedListener(supportedListener);
465         speechRecognizer.addSpeechResultListener(transcript -> {
466             if (!transcript.isEmpty()) {
467                 findBarTextField.setValue(transcript);
468                 launchCommand();
469             }
470             isRecording.set(false);
471         });
472 
473         voiceButton.addClickListener((Button.ClickListener) event -> {
474             if (isRecording.get()) {
475                 isRecording.set(false);
476                 speechRecognizer.abort();
477                 return;
478             }
479             isRecording.set(true);
480             speechRecognizer.record();
481         });
482     }
483 
484     @Override
485     public Component.Focusable asVaadinComponent() {
486         return findBarLayout;
487     }
488 
489     /**
490      * Detect whether a given supplier corresponds to an app disabled for the current user through permissions.
491      */
492     private boolean isForbiddenApp(SearchResultSupplier supplier, AppLauncherLayout userLayout) {
493         return supplier instanceof JcrSearchResultSupplier &&
494                 !Optional.ofNullable(((JcrSearchResultSupplier) supplier).getAppName())
495                         .map(userLayout::containsApp)
496                         .orElse(true);
497     }
498 
499     protected synchronized void search() {
500         search(false);
501     }
502 
503     protected synchronized void search(boolean forceSearch) {
504         resultsGrid.clear();
505         resultsGrid.dropSelection();
506 
507         SearchQuery searchQuery = buildSearchQuery();
508 
509         if (!forceSearch && searchQuery.equals(latestQuery) && StringUtils.equals(currentApp, latestApp)) {
510             return;
511         }
512         latestQuery = searchQuery;
513 
514         FindBarConfiguration findBarConfiguration = configProvider.get();
515         // Going back to default 10 items for usual search use case.
516         searchQuery.setLimitPerSupplier(findBarConfiguration.getDefaultCountPerSupplier());
517 
518         // We pass the current query to all registered sniffers (which will decide whether they take action or not)
519         if (forceSearch && StringUtils.isNotBlank(searchQuery.getQuery())) {
520             Optional<OperationRequest> operationRequestOptional = periscope.sniffQuery(searchQuery.getQuery());
521             if (operationRequestOptional.isPresent()) {
522                 findBarTextField.focus();
523                 findBarTextField.setCommand(operationRequestOptional.get());
524                 findBarButton.setIcon(MagnoliaIcons.ENTER_ARROW);
525                 return;
526             }
527             findBarTextField.clearCommand();
528             findBarButton.setIcon(MagnoliaIcons.SEARCH);
529         } else {
530             // Query is empty which means we should pull suggestions.
531             findBarTextField.clearCommand();
532             findBarButton.setIcon(MagnoliaIcons.SEARCH);
533             // Using default '3' items for suggestions
534             if (hasNoExtraQueryCriteria()) {
535                 searchQuery.setLimitPerSupplier(findBarConfiguration.getSuggestionCountPerSupplier());
536             }
537         }
538 
539         List<SearchResultSupplier> suppliers = buildSuppliersOrder(searchQuery);
540 
541         updateCurrentSupplier();
542 
543         // If there are searches to execute, we enable polling because results are supplied to front end in batches.
544         final UI currentUi = UI.getCurrent();
545         if (suppliers.size() > 0) {
546             currentUi.setPollInterval(100);
547             resultsGrid.addStyleName("loading");
548         }
549 
550         resultCollector.search(searchQuery, suppliers, () -> {
551             // only reset polling if no subsequent search has already been triggered
552             if (latestQuery == searchQuery) {
553                 currentUi.access(() -> {
554                     currentUi.setPollInterval(DEFAULT_POLL_INTERVAL);
555                     resultsGrid.removeStyleName("loading");
556                     if (findBarTextField.hasFocus() && StringUtils.isNotEmpty(searchQuery.getQuery())) {
557                         resultsGrid.selectFirstResult();
558                     }
559                 });
560             }
561         });
562     }
563 
564     private void updateCurrentSupplier() {
565         if (!appBiasToggle.getParent().isVisible() || !appBiasToggle.getValue()) {
566             resultsGrid.setCurrentAppSupplier(null);
567             return;
568         }
569 
570         Optional<SearchResultSupplier> currentSupplier = availableResultSuppliers.stream()
571                 .filter(s -> s.getName().equals(currentApp))
572                 .findAny();
573         resultsGrid.setCurrentAppSupplier(currentSupplier.orElse(null));
574     }
575 
576     /**
577      * Build sorted supplier list based on configuration and other applicable restrictions.
578      */
579     List<SearchResultSupplier> buildSuppliersOrder(SearchQuery searchQuery) {
580         Set<String> selectedSuppliers = this.supplierField.getValue();
581         Stream<SearchResultSupplier> suppliers = CollectionUtils.isEmpty(selectedSuppliers) ? availableResultSuppliers.stream() :
582                 availableResultSuppliers.stream().filter(e -> selectedSuppliers.contains(e.getName()));
583 
584         List<String> configuredSuppliers = configProvider.get().getSupplierOrder();
585         if (StringUtils.isBlank(searchQuery.getQuery()) && CollectionUtils.isNotEmpty(configuredSuppliers)) {
586             suppliers = suppliers.filter(supplier ->
587                     configuredSuppliers.contains(supplier.getName()) || supplier.getName().equals(currentApp));
588         }
589 
590         suppliers = suppliers.sorted((a, b) -> {
591             if (appBiasToggle.getValue()) {
592                 // move supplier for current app to top
593                 if (a.getName().equals(currentApp)) {
594                     return -1;
595                 } else if (b.getName().equals(currentApp)) {
596                     return 1;
597                 }
598             }
599             // use default sorter for everything else
600             return sorter.compare(a, b);
601         });
602 
603         return suppliers.collect(toList());
604     }
605 
606     private SearchQuery buildSearchQuery() {
607         SearchQuery.SearchQueryBuilder searchQueryBuilder = SearchQuery.builder()
608                 .query(this.findBarTextField.getValue().trim())
609                 .tags(this.tagField.getValue())
610                 .editors(this.editorField.getValue())
611                 .currentUser(this.currentUser);
612 
613         //TODO: Those ideally should come via user profile!
614         if (this.dateRangeField.getBeginDateField().getValue() != null) {
615             searchQueryBuilder.startDate(this.dateRangeField.getBeginDateField().getValue().atStartOfDay(ZoneId.systemDefault()));
616         }
617         if (this.dateRangeField.getEndDateField().getValue() != null) {
618             searchQueryBuilder.endDate(this.dateRangeField.getEndDateField().getValue().atTime(LocalTime.MAX).atZone(ZoneId.systemDefault()));
619         }
620 
621         return searchQueryBuilder.build();
622     }
623 
624     private boolean hasNoExtraQueryCriteria() {
625         return CollectionUtils.isEmpty(editorField.getValue()) && CollectionUtils.isEmpty(tagField.getValue()) &&
626                 CollectionUtils.isEmpty(supplierField.getValue()) && dateRangeField.getBeginDate() == null && dateRangeField.getEndDate() == null;
627     }
628 
629     private List<String> resolveUsers() {
630         return Optional.ofNullable(configProvider.get().getEditorRoles())
631                 .map(roles -> roles.stream().flatMap(role -> securitySupport.getUserManager().getUsersWithRole(role).stream()))
632                 .orElseGet(() -> securitySupport.getUserManager().getAllUsers().stream().map(User::getName))
633                 .collect(toList());
634     }
635 
636     /**
637      * Package visibility for testing purposes only.
638      */
639     FindBarTextField getTextField() {
640         return findBarTextField;
641     }
642 
643     /**
644      * Package visibility for testing purposes only.
645      */
646     ComboBoxMultiselect<String> getSupplierField() {
647         return supplierField;
648     }
649 
650     private class FindBarLayout extends AbsoluteLayout implements Component.Focusable {
651 
652         @Override
653         public void focus() {
654             findBarTextField.focus();
655         }
656 
657         @Override
658         public int getTabIndex() {
659             return 0;
660         }
661 
662         @Override
663         public void setTabIndex(int tabIndex) {
664         }
665     }
666 }