1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
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
81
82 private final Map<String, SupplierAwareSearchResult> firstResultsPerSupplier = new HashMap<>();
83
84
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
98 ((SelectionModel.Single<?>) selectionModel).setDeselectAllowed(false);
99
100 this.grid.addItemClickListener(event -> {
101
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
277
278 void triggerSelected() {
279
280 grid.focus();
281
282 Optional<SupplierAwareSearchResult> selected = grid.getSelectedItems().stream().findFirst();
283 if (selected.isPresent()) {
284 triggerResult(selected.get());
285 } else {
286
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 }