Skip to content

Commit ede79c0

Browse files
authored
CIF-2686 - Filter by category in product list component UI (#921)
* CIF-2686 - Filter by category in product list component UI * enabled category filter for product list, only direct child categories are included * updated unit tests * CIF-2686 - Filter by category in product list component UI * addressing code review: better support for custom aggregation options * CIF-2686 - Filter by category in product list component UI * small fixes
1 parent a510ff9 commit ede79c0

10 files changed

Lines changed: 219 additions & 52 deletions

File tree

bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/models/v1/productlist/CategoryRetriever.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ protected CategoryTreeQueryDefinition generateCategoryQuery() {
3838
.metaKeywords()
3939
.metaTitle()
4040
.urlKey()
41-
.urlPath();
41+
.urlPath()
42+
.children(cq -> cq.id().uid().urlKey().urlPath());
4243

4344
if (categoryQueryHook != null) {
4445
categoryQueryHook.accept(q);

bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/models/v1/productlist/ProductListImpl.java

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
import java.io.IOException;
1919
import java.util.Collection;
2020
import java.util.Collections;
21+
import java.util.List;
2122
import java.util.Locale;
2223
import java.util.Map;
2324
import java.util.Objects;
25+
import java.util.Optional;
2426
import java.util.function.Consumer;
2527
import java.util.stream.Collectors;
2628

@@ -52,12 +54,16 @@
5254
import com.adobe.cq.commerce.core.components.storefrontcontext.CategoryStorefrontContext;
5355
import com.adobe.cq.commerce.core.components.utils.SiteNavigation;
5456
import com.adobe.cq.commerce.core.search.internal.converters.ProductToProductListItemConverter;
57+
import com.adobe.cq.commerce.core.search.internal.models.SearchAggregationOptionImpl;
5558
import com.adobe.cq.commerce.core.search.internal.models.SearchOptionsImpl;
5659
import com.adobe.cq.commerce.core.search.internal.models.SearchResultsSetImpl;
60+
import com.adobe.cq.commerce.core.search.models.SearchAggregation;
61+
import com.adobe.cq.commerce.core.search.models.SearchAggregationOption;
5762
import com.adobe.cq.commerce.core.search.models.SearchResultsSet;
5863
import com.adobe.cq.commerce.core.search.models.Sorter;
5964
import com.adobe.cq.commerce.magento.graphql.CategoryInterface;
6065
import com.adobe.cq.commerce.magento.graphql.CategoryProducts;
66+
import com.adobe.cq.commerce.magento.graphql.CategoryTree;
6167
import com.adobe.cq.commerce.magento.graphql.ProductInterfaceQuery;
6268
import com.adobe.cq.sightly.SightlyWCMMode;
6369
import com.day.cq.wcm.api.Page;
@@ -76,6 +82,7 @@ public class ProductListImpl extends ProductCollectionImpl implements ProductLis
7682
private static final boolean SHOW_TITLE_DEFAULT = true;
7783
private static final boolean SHOW_IMAGE_DEFAULT = true;
7884
private static final String CATEGORY_PROPERTY = "category";
85+
static final String CATEGORY_AGGREGATION_ID = "category_id";
7986

8087
private boolean showTitle;
8188
private boolean showImage;
@@ -160,8 +167,7 @@ protected void initModel() {
160167
@Nullable
161168
@Override
162169
public String getTitle() {
163-
return getCategory() != null ? getCategory().getName()
164-
: StringUtils.EMPTY;
170+
return getCategory() != null ? getCategory().getName() : StringUtils.EMPTY;
165171
}
166172

167173
@Override
@@ -209,15 +215,66 @@ public SearchResultsSet getSearchResultsSet() {
209215
if (searchResultsSet == null) {
210216
searchResultsSet = getCategorySearchResultsSet().getRight();
211217

212-
((SearchResultsSetImpl) searchResultsSet).setSearchAggregations(
213-
searchResultsSet.getSearchAggregations()
214-
.stream()
215-
.filter(searchAggregation -> !SearchOptionsImpl.CATEGORY_UID_PARAMETER_ID.equals(searchAggregation.getIdentifier()))
216-
.collect(Collectors.toList()));
218+
List<SearchAggregation> searchAggregations = searchResultsSet.getSearchAggregations()
219+
.stream()
220+
.filter(searchAggregation -> !SearchOptionsImpl.CATEGORY_UID_PARAMETER_ID.equals(searchAggregation.getIdentifier()))
221+
.collect(Collectors.toList());
222+
223+
CategoryTree categoryTree = (CategoryTree) getCategorySearchResultsSet().getLeft();
224+
processCategoryAggregation(searchAggregations, categoryTree);
225+
226+
((SearchResultsSetImpl) searchResultsSet).setSearchAggregations(searchAggregations);
217227
}
218228
return searchResultsSet;
219229
}
220230

231+
private void processCategoryAggregation(List<SearchAggregation> searchAggregations, CategoryTree categoryTree) {
232+
if (categoryTree != null && categoryTree.getChildren() != null) {
233+
List<CategoryTree> childCategories = categoryTree.getChildren();
234+
searchAggregations.stream().filter(aggregation -> CATEGORY_AGGREGATION_ID.equals(aggregation.getIdentifier())).findAny()
235+
.ifPresent(categoryAggregation -> {
236+
List<SearchAggregationOption> options = categoryAggregation.getOptions();
237+
238+
// find and process category aggregation options related to child categories of current category
239+
List<SearchAggregationOption> filteredOptions = options.stream().map(
240+
option -> option instanceof SearchAggregationOptionImpl ? (SearchAggregationOptionImpl) option
241+
: new SearchAggregationOptionImpl(option)).filter(option -> {
242+
Optional<CategoryTree> categoryRef = childCategories.stream().filter(c -> String.valueOf(c.getId()).equals(
243+
option.getFilterValue())).findAny();
244+
245+
if (categoryRef.isPresent()) {
246+
CategoryTree category = categoryRef.get();
247+
CategoryUrlFormat.Params params = new CategoryUrlFormat.Params();
248+
params.setUid(category.getUid().toString());
249+
params.setUrlKey(category.getUrlKey());
250+
params.setUrlPath(category.getUrlPath());
251+
option.setPageUrl(urlProvider.toCategoryUrl(request, currentPage, params));
252+
option.getAddFilterMap().remove(CATEGORY_AGGREGATION_ID);
253+
return true;
254+
} else {
255+
return false;
256+
}
257+
}).collect(Collectors.toList());
258+
259+
// keep filtered options only or remove category aggregation if no option was found
260+
if (filteredOptions.isEmpty()) {
261+
searchAggregations.removeIf(a -> CATEGORY_AGGREGATION_ID.equals(a.getIdentifier()));
262+
} else {
263+
options.clear();
264+
options.addAll(filteredOptions);
265+
// move category aggregation to front
266+
searchAggregations.stream().filter(a -> CATEGORY_AGGREGATION_ID.equals(a.getIdentifier())).findAny().ifPresent(
267+
aggregation -> {
268+
searchAggregations.remove(aggregation);
269+
searchAggregations.add(0, aggregation);
270+
});
271+
}
272+
});
273+
} else {
274+
searchAggregations.removeIf(a -> CATEGORY_AGGREGATION_ID.equals(a.getIdentifier()));
275+
}
276+
}
277+
221278
private Pair<CategoryInterface, SearchResultsSet> getCategorySearchResultsSet() {
222279
if (categorySearchResultsSet == null) {
223280
Consumer<ProductInterfaceQuery> productQueryHook = categoryRetriever != null ? categoryRetriever.getProductQueryHook() : null;

bundles/core/src/main/java/com/adobe/cq/commerce/core/search/internal/models/SearchAggregationOptionImpl.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ public class SearchAggregationOptionImpl implements SearchAggregationOption {
3030
private String displayLabel;
3131
private int count;
3232
private Map<String, String> addFilterMap;
33+
private String pageUrl;
34+
35+
public SearchAggregationOptionImpl() {}
36+
37+
public SearchAggregationOptionImpl(SearchAggregationOption that) {
38+
this.filterValue = that.getFilterValue();
39+
this.displayLabel = that.getDisplayLabel();
40+
this.count = that.getCount();
41+
this.addFilterMap = that.getAddFilterMap();
42+
this.pageUrl = that.getPageUrl();
43+
}
3344

3445
@Nonnull
3546
@Override
@@ -55,6 +66,15 @@ public Map<String, String> getAddFilterMap() {
5566
return addFilterMap;
5667
}
5768

69+
@Override
70+
public String getPageUrl() {
71+
return pageUrl;
72+
}
73+
74+
public void setPageUrl(String pageUrl) {
75+
this.pageUrl = pageUrl;
76+
}
77+
5878
public void setFilterValue(final String filterValue) {
5979
this.filterValue = filterValue;
6080
}
@@ -70,4 +90,5 @@ public void setCount(final int count) {
7090
public void setAddFilterMap(final Map<String, String> addFilterMap) {
7191
this.addFilterMap = addFilterMap;
7292
}
93+
7394
}

bundles/core/src/main/java/com/adobe/cq/commerce/core/search/internal/services/SearchResultsServiceImpl.java

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,6 @@ public Pair<QueryQuery.CategoryListArgumentsDefinition, CategoryTreeQueryDefinit
207207
mutableSearchOptions.getAllFilters(), availableFilters);
208208

209209
// special handling of category identifier(s)
210-
removeCategoryIdAggregationIfNecessary(searchAggregations, mutableSearchOptions);
211210
removeCategoryUidFilterEntriesIfPossible(searchAggregations, request);
212211

213212
searchResultsSet.setTotalResults(products.getTotalCount());
@@ -508,20 +507,6 @@ private List<SearchAggregation> extractSearchAggregationsFromResponse(
508507
.collect(Collectors.toList());
509508
}
510509

511-
/**
512-
* Removes the category_id filter from the search aggregations when category_uid filter is present because either cannot be combined
513-
* with the other.
514-
*
515-
* @param aggs
516-
* @param searchOptions
517-
*/
518-
private void removeCategoryIdAggregationIfNecessary(List<SearchAggregation> aggs, SearchOptionsImpl searchOptions) {
519-
// Special treatment for category_id filter as this is always present and collides with category_uid filter (CIF-2206)
520-
if (searchOptions.getCategoryUid().isPresent()) {
521-
aggs.removeIf(agg -> CATEGORY_ID_FILTER.equals(agg.getIdentifier()));
522-
}
523-
}
524-
525510
/**
526511
* Removes the category_uid filter from all filter options' filter maps when the category uid can be retrieved from the request already.
527512
*

bundles/core/src/main/java/com/adobe/cq/commerce/core/search/models/SearchAggregationOption.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,13 @@ public interface SearchAggregationOption {
5959
@Nonnull
6060
Map<String, String> getAddFilterMap();
6161

62+
/**
63+
* Get the page URL for this aggregation option.
64+
*
65+
* @return page url
66+
*/
67+
default String getPageUrl() {
68+
return null;
69+
}
70+
6271
}

bundles/core/src/main/java/com/adobe/cq/commerce/core/search/models/package-info.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
~ See the License for the specific language governing permissions and
1414
~ limitations under the License.
1515
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
16-
@Version("3.1.0")
16+
@Version("3.2.0")
1717
package com.adobe.cq.commerce.core.search.models;
1818

1919
import org.osgi.annotation.versioning.Version;

bundles/core/src/test/java/com/adobe/cq/commerce/core/components/internal/models/v1/productlist/ProductListImplTest.java

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,18 @@
1616
package com.adobe.cq.commerce.core.components.internal.models.v1.productlist;
1717

1818
import java.io.IOException;
19+
import java.lang.reflect.Proxy;
1920
import java.text.NumberFormat;
2021
import java.util.Collection;
2122
import java.util.Currency;
2223
import java.util.List;
2324
import java.util.Locale;
2425
import java.util.Map;
2526
import java.util.Optional;
27+
import java.util.function.Consumer;
2628
import java.util.stream.Collectors;
2729

30+
import org.apache.commons.lang3.tuple.Pair;
2831
import org.apache.http.HttpStatus;
2932
import org.apache.http.impl.client.CloseableHttpClient;
3033
import org.apache.http.osgi.services.HttpClientBuilderFactory;
@@ -58,17 +61,22 @@
5861
import com.adobe.cq.commerce.core.search.internal.services.SearchFilterServiceImpl;
5962
import com.adobe.cq.commerce.core.search.internal.services.SearchResultsServiceImpl;
6063
import com.adobe.cq.commerce.core.search.models.SearchAggregation;
64+
import com.adobe.cq.commerce.core.search.models.SearchAggregationOption;
65+
import com.adobe.cq.commerce.core.search.models.SearchOptions;
6166
import com.adobe.cq.commerce.core.search.models.SearchResultsSet;
6267
import com.adobe.cq.commerce.core.search.models.Sorter;
6368
import com.adobe.cq.commerce.core.search.models.SorterKey;
69+
import com.adobe.cq.commerce.core.search.services.SearchResultsService;
6470
import com.adobe.cq.commerce.core.testing.Utils;
6571
import com.adobe.cq.commerce.graphql.client.GraphqlClient;
6672
import com.adobe.cq.commerce.graphql.client.GraphqlRequest;
6773
import com.adobe.cq.commerce.graphql.client.impl.GraphqlClientImpl;
74+
import com.adobe.cq.commerce.magento.graphql.CategoryInterface;
6875
import com.adobe.cq.commerce.magento.graphql.CategoryTree;
6976
import com.adobe.cq.commerce.magento.graphql.GroupedProduct;
7077
import com.adobe.cq.commerce.magento.graphql.ProductImage;
7178
import com.adobe.cq.commerce.magento.graphql.ProductInterface;
79+
import com.adobe.cq.commerce.magento.graphql.ProductInterfaceQuery;
7280
import com.adobe.cq.commerce.magento.graphql.Products;
7381
import com.adobe.cq.commerce.magento.graphql.Query;
7482
import com.adobe.cq.commerce.magento.graphql.gson.QueryDeserializer;
@@ -305,12 +313,103 @@ public void getProducts() {
305313

306314
SearchResultsSet searchResultsSet = productListModel.getSearchResultsSet();
307315
List<SearchAggregation> searchAggregations = searchResultsSet.getSearchAggregations();
308-
Assert.assertEquals(7, searchAggregations.size());
316+
Assert.assertEquals(8, searchAggregations.size());
309317

310-
// We want to make sure the category_id aggregation is not present
311-
Optional<SearchAggregation> categoryIdAggregation = searchAggregations.stream().filter(a -> a.getIdentifier().equals("category_id"))
318+
// check category aggregation
319+
Optional<SearchAggregation> categoryIdAggregation = searchAggregations.stream().filter(a -> a.getIdentifier().equals(
320+
ProductListImpl.CATEGORY_AGGREGATION_ID))
312321
.findAny();
313-
Assert.assertFalse(categoryIdAggregation.isPresent());
322+
Assert.assertTrue(categoryIdAggregation.isPresent());
323+
List<SearchAggregationOption> options = categoryIdAggregation.get().getOptions();
324+
Assert.assertEquals(2, options.size());
325+
326+
SearchAggregationOption opt = options.get(0);
327+
Assert.assertEquals("3", opt.getFilterValue());
328+
Assert.assertEquals("Gear", opt.getDisplayLabel());
329+
Assert.assertEquals("/content/pageA.html/running/gear.html", opt.getPageUrl());
330+
331+
opt = options.get(1);
332+
Assert.assertEquals("4", opt.getFilterValue());
333+
Assert.assertEquals("Bags", opt.getDisplayLabel());
334+
Assert.assertEquals("/content/pageA.html/running/bags.html", opt.getPageUrl());
335+
336+
// We want to make sure all price ranges are properly processed
337+
SearchAggregation priceAggregation = searchAggregations.stream().filter(a -> a.getIdentifier().equals("price")).findFirst().get();
338+
Assert.assertEquals(3, priceAggregation.getOptions().size());
339+
Assert.assertEquals(3, priceAggregation.getOptionCount());
340+
Assert.assertTrue(priceAggregation.getOptions().stream().anyMatch(o -> o.getDisplayLabel().equals("30-40")));
341+
Assert.assertTrue(priceAggregation.getOptions().stream().anyMatch(o -> o.getDisplayLabel().equals("40-*")));
342+
Assert.assertTrue(priceAggregation.getOptions().stream().anyMatch(o -> o.getDisplayLabel().equals("14")));
343+
}
344+
345+
// custom marker interface for search aggregation options
346+
private interface MySearchAggregationOption {};
347+
348+
@Test
349+
public void getProductsWithCustomAggregationOptions() {
350+
adaptToProductList();
351+
352+
// inject custom search results service which returns custom search aggregation objects
353+
SearchResultsService searchResultsService = (SearchResultsService) Whitebox.getInternalState(productListModel,
354+
"searchResultsService");
355+
ClassLoader classLoader = getClass().getClassLoader();
356+
Whitebox.setInternalState(productListModel, "searchResultsService", Proxy.newProxyInstance(classLoader,
357+
new Class[] { SearchResultsService.class }, (proxy, method, args) -> {
358+
if (method.getName().equals("performSearch") && method.getParameterCount() == 6) {
359+
Pair<CategoryInterface, SearchResultsSet> pair = searchResultsService.performSearch(
360+
(SearchOptions) args[0],
361+
(Resource) args[1],
362+
(Page) args[2],
363+
(SlingHttpServletRequest) args[3],
364+
(Consumer<ProductInterfaceQuery>) args[4],
365+
(AbstractCategoryRetriever) args[5]);
366+
367+
Class[] optionInterfaces = { SearchAggregationOption.class, MySearchAggregationOption.class };
368+
for (SearchAggregation aggregation : pair.getRight().getSearchAggregations()) {
369+
List<SearchAggregationOption> options = aggregation.getOptions();
370+
List<SearchAggregationOption> myOptions = options.stream().map(
371+
o -> (SearchAggregationOption) Proxy.newProxyInstance(classLoader, optionInterfaces,
372+
(oProxy, oMethod, oArgs) -> oMethod.invoke(o, oArgs))).collect(Collectors.toList());
373+
options.clear();
374+
options.addAll(myOptions);
375+
}
376+
377+
return pair;
378+
} else {
379+
return method.invoke(searchResultsService, args);
380+
}
381+
}));
382+
383+
Collection<ProductListItem> products = productListModel.getProducts();
384+
Assert.assertNotNull(products);
385+
386+
// We introduce one "faulty" product data in the response, it should be skipped
387+
Assert.assertEquals(4, products.size());
388+
389+
SearchResultsSet searchResultsSet = productListModel.getSearchResultsSet();
390+
List<SearchAggregation> searchAggregations = searchResultsSet.getSearchAggregations();
391+
Assert.assertEquals(8, searchAggregations.size());
392+
393+
// check category aggregation
394+
Optional<SearchAggregation> categoryIdAggregation = searchAggregations.stream().filter(a -> a.getIdentifier().equals(
395+
ProductListImpl.CATEGORY_AGGREGATION_ID))
396+
.findAny();
397+
Assert.assertTrue(categoryIdAggregation.isPresent());
398+
List<SearchAggregationOption> options = categoryIdAggregation.get().getOptions();
399+
Assert.assertEquals(2, options.size());
400+
401+
SearchAggregationOption opt = options.get(0);
402+
// for category aggregation custom options are replaced
403+
Assert.assertFalse(opt instanceof MySearchAggregationOption);
404+
Assert.assertEquals("3", opt.getFilterValue());
405+
Assert.assertEquals("Gear", opt.getDisplayLabel());
406+
Assert.assertEquals("/content/pageA.html/running/gear.html", opt.getPageUrl());
407+
408+
opt = options.get(1);
409+
Assert.assertFalse(opt instanceof MySearchAggregationOption);
410+
Assert.assertEquals("4", opt.getFilterValue());
411+
Assert.assertEquals("Bags", opt.getDisplayLabel());
412+
Assert.assertEquals("/content/pageA.html/running/bags.html", opt.getPageUrl());
314413

315414
// We want to make sure all price ranges are properly processed
316415
SearchAggregation priceAggregation = searchAggregations.stream().filter(a -> a.getIdentifier().equals("price")).findFirst().get();
@@ -319,6 +418,9 @@ public void getProducts() {
319418
Assert.assertTrue(priceAggregation.getOptions().stream().anyMatch(o -> o.getDisplayLabel().equals("30-40")));
320419
Assert.assertTrue(priceAggregation.getOptions().stream().anyMatch(o -> o.getDisplayLabel().equals("40-*")));
321420
Assert.assertTrue(priceAggregation.getOptions().stream().anyMatch(o -> o.getDisplayLabel().equals("14")));
421+
422+
// for other aggregations custom options are preserved
423+
Assert.assertTrue(priceAggregation.getOptions().get(0) instanceof MySearchAggregationOption);
322424
}
323425

324426
@Test

0 commit comments

Comments
 (0)