Skip to Content
Technical Articles

Combining Advanced Search and Simple Search in the SAP Commerce Backoffice

Introduction

In the Hybris backoffice, there are two main types of searching – simple search which uses the SOLR engine and advanced search which uses the OOTB FlexibleSearchService. The first one searches over all the configured SolrIndexedProperties, while the second one is limited only for some of the attributes of the model itself (-items.xml). 

Therefore, there is no functionality which offers a more detailed search over specific SOLR attributes. Possible applications of such a functionality are: 

  • searching over dynamic attributes
  • searching over non-dynamic attributes which cannot be part of the advanced search (e.g. attributes part of many-to-many relations)
  • searching for a Product which has a classification attribute with particular value (e.g. starts with/contains)

Solution

In the PCM there is the Simple Search and Filters Widget (also known as the Fulltextsearch Widget), which combines SOLR search and advanced search: it has a list of attributes over which search can be performed, just like using advanced search, but the attributes themselves can be SolrIndexedProperties (see Fig.1. below).

Fig.1.%20Simple%20Search%20and%20Filters%20Widget

Fig.1. Simple Search and Filters widget with an added filter (source: Simple Search and Filters Widget Official Documentation)

A possible solution is to incorporate the Fulltextsearch widget in the Administration Cockpit’s AdvancedSearch widget, so that we can get the best of both worlds.

1. Override the OOTB AdvancedSearch widget so that it contains the Fulltextsearch widget

In order to override the OOTB Advanced widget, we need to create a custom widget, which extends the OOTB one. In order to do so, we should first copy the contents of the /backoffice/resources/widgets/advancedsearch folder in our extension. This folder has the following contents:

  • definition.xml
  • ZUL file – contains the definitions of the widget’s UI elements
  • CSS and SCSS files
  • Images folder
  • Labels folder

1.1. Add the Fulltextsearch widget to the AdvancedSearch widget’s UI

First, we will start with extending the UI of our custom widget. The following snippet should be added to the .zul file of the widget (which should be in the /backoffice/resources/widgets/customwidget folder):

<div id="fullTextSearchContainer" sclass="yw-fulltextsearch-container">
    <div class="yw-fulltextsearch-wrapper yw-fulltextsearch-filter-wrapper">
        <div id="fieldQueryFiltersCounterLabelWrapper" sclass="yw-fulltextsearch-filters-counter-wrapper">
            <label id="fieldQueryFiltersCounterLabel" sclass="yw-fulltextsearch-filters-counter"/>
        </div>
        <button id="fieldQueryButton" sclass="yw-fulltextsearch-fieldquery-button" visible="false"/>
        <popup id="fieldQueryPopup" sclass="yw-fulltextsearch-fieldquery-popup"/>
    </div>
</div>

The fullTextSearchContainer will hold all the UI elements of the new sub-widget. The fieldQueryButton is the filter icon which after being clicked, opens the fieldQueryPopup. The fieldQueryPopup contains the filters which are already added as well as the Add more filters button and the Apply button used for triggering the search (see Fig.1. above).

After adding the UI elements, we now need to add some additional styling so that the sub-widget is situated in the right place and has proper appearance. The following styles can be added to a new file (in the /backoffice/resources/widgets/customwidget/scss/components folder, e.g. _advancedsearch-fulltextsearch.scss):

.yw-fulltextsearch-container {
  text-align: left !important;
}

.yw-fulltextsearch-wrapper {
  margin-top: 5px;
}

.yw-fulltextsearch-filters-counter-wrapper {
  min-width: 0 !important;
  margin-left: 16px !important;
}

.yw-fulltextsearch-filters-counter {
  line-height: 7px !important;
  color: white !important;
  font-size: small !important;
}

The file can now be imported in the customwidget’s styles (/backoffice/resources/widgets/customwidget/scss/customwidget.scss by adding the following line:

@import "scss/components/_advancedsearch-fulltextsearch.scss";

1.2. Extend the AdvancedSearch widget controller, so that it contains the Fulltextsearch widget’s functionality as well

First, we should @Wire the UI components in the custom widget’s controller, add the renderer for the sub-widget pop-up and generate their getters and setters. This way we will have access to the UI elements we defined in the ZUL file from within the controller. The controller class should extend the OOTB com.hybris.backoffice.widgets.advancedsearch.AdvancedSearchController (so that we have all the OOTB implementation logic and just extend what is needed for the new sub-widget) and should be part of the /customextension/backoffice/src source directory (as opposed to /customextension/src):

public class CustomAdvancedSearchController extends AdvancedSearchController {
@Wire
private Div fullTextSearchContainer;

@Wire
private Popup fieldQueryPopup;

@Wire
private Button fieldQueryButton;

@Wire
private Label fieldQueryFiltersCounterLabel;

private transient WidgetComponentRenderer<Popup, FulltextSearch, AdvancedSearchData> fieldQueryPopupRenderer;

Then, we need to initialize the components of the sub-widget. The initialization takes place after each backoffice login. In order to do this, we will override the OOTB initialize(Component comp) method – first we will make sure we have the OOTB initialization implementation for the advanced search widget itself by calling its super method, and then we will proceed with initializing the newly added UI components via the initializeFieldQueryComponents() and initializeFieldQueryFiltersCounterLabel() methods:

@Override
public void initialize(Component comp) {
    super.initialize(comp);
    initializeFieldQueryComponents();
    initializeFieldQueryFiltersCounterLabel();
}

private void initializeFieldQueryComponents() {
    getFieldQueryButton().setVisible(true);
    getFieldQueryButton().setTooltiptext(getLabel("fieldquerybutton.tooltip"));
    getFieldQueryButton().addEventListener("onClick", (e) -> {
        adjustFieldQuery();
        getFieldQueryPopup().open(e.getTarget(), "after_end");
    });
}

The initializeFieldQueryComponents() method is responsible for the filter button – it makes it visible and holds the logic about what happens when it gets clicked.

private void adjustFieldQuery() {
    getFieldQueryPopup().getChildren().clear();
    if (getCurrentDataType() != null) {
        FulltextSearch configuration = loadFullTextConfiguration(getCurrentDataType().getCode());
        AdvancedSearchData searchData = getSearchModel().orElse(null);
        getFieldQueryPopupRenderer().render(getFieldQueryPopup(), configuration, searchData, getCurrentDataType(), getWidgetInstanceManager());
        }
}

The adjustFieldQuery() method loads the sub-widget configuration (from the -config.xml files) and renders the available filters according to it, via the fieldQueryPopupRenderer.

public DataType getCurrentDataType() {
    return getValue("dataType", DataType.class);
}

The getCurrentDataType() method gets the type we are currently searching for from the widget’s values. It is set while changing the type, in the performChangeType(String typeCode, boolean rootTypeChanged) method which is covered later in this article.

private FulltextSearch loadFullTextConfiguration(String type) {
    String configCtxCode = StringUtils.defaultIfBlank(getWidgetSettings().getString("fulltextSearchConfigCtxCode"), "fulltext-search");
    DefaultConfigContext configContext = new DefaultConfigContext(configCtxCode, StringUtils.trim(type));
    return super.loadConfiguration(configContext, FulltextSearch.class);
}

As its name implies, the loadFullTextConfiguration() method loads the sub-widget configuration. This configuration can be part of any of the -backoffice-config.xml files and basically consists of a list of the filters which should be available from the drop-down list of the pop-up. The ID of the component in the -backoffice-config.xml files is configured via the fulltextSearchConfigCtxCode setting, from within the widget’s definition.xml file.

private void initializeFieldQueryFiltersCounterLabel() {        getFieldQueryFiltersCounterLabel().setSclass("yw-fulltextsearch-filters-counter");
    Integer numberOfFilters = getModel().getValue("filtersCounter", Integer.class);
    if (numberOfFilters == null) {
        getFieldQueryFiltersCounterLabel().setValue("0");
    } else {          
        getFieldQueryFiltersCounterLabel().setValue(String.valueOf(numberOfFilters));
    }
}

The initializeFieldQueryFiltersCounterLabel() method is responsible for the bubble on the top right corner of the filter button which holds the number of currently enabled filters.

After we have initialized the widget UI components, we need to add the main functionality for adding new filters and including them in the search:

@ViewEvent(
    componentID = "fieldQueryPopup",
    eventName = "onApplyFilters"
)
public void onApplyFilters(Event event) {
    if (canProcessFilterChangeEvent(event)) {
        Map<String, FullTextSearchFilter> filters = (Map)event.getData();
        setValue("fieldQueryFilters", filters);
        List<SearchConditionData> conditions = buildSearchConditionData(filters);
        updateFilterCounter(filters);
        setValue("fieldQueries", conditions);
        doSearch();
    }
}

The onApplyFilters(Event event) method holds the implementation logic of what happens after the Apply button of the sub-widget pop-up is clicked – it forms a query using all the filters which are set, updates the number of active filters in the top right corner of the filter button and performs a search.

private boolean canProcessFilterChangeEvent(Event event) {
    return event != null && event.getData() != null && event.getData() instanceof Map;
}

In order to be able to take the filters into account for the search, we expect a particular, non-null Event, which should hold the filters data, stored in a Map object.

private List<SearchConditionData> buildSearchConditionData(Map<String, FullTextSearchFilter> filters) {
    List<SearchConditionData> conditions = Lists.newArrayList();
    filters.values().stream()
                    .filter(FullTextSearchFilter::isEnabled)
                    .filter(this::isNotEmptyFilterConditions)
                    .forEach((filter) -> {
                                    FieldType fieldType = new FieldType();
                                    fieldType.setName(filter.getName());
                                    SearchConditionData condition = new SearchConditionData(fieldType, filter.getValue(), filter.getOperator());
                                   clearLocalizedValues(filter.getValue(), filter.getLocale());
                                    conditions.add(condition);
                            });
    return conditions;
}

The buildSearchConditionData(Map<String, FullTextSearchFilter> filter) gets all the filters (which are passed from the event) and for each of them creates a SearchConditionData object which is then used by the actual search.

private boolean isNotEmptyFilterConditions(FullTextSearchFilter filter) {
    if (filter.getValue() instanceof Map) {
        Map<Locale, Object> localizedValue = (Map)filter.getValue();
        return localizedValue.values().stream().noneMatch(""::equals);
    } else {
        return !filter.getOperator().isRequireValue() || filter.getValue() != null;
    }
}

private void clearLocalizedValues(Object value, Locale locale) {
    if (locale != null && value != null && value instanceof Map) {
        Map<Locale, Object> localizedValues = (Map)value;
        if (!localizedValues.isEmpty()) {
            localizedValues.entrySet().removeIf((entry) -> !entry.getKey().equals(locale));
        }
    }
}

private void updateFilterCounter(Map<String, FullTextSearchFilter> filters) {
    int numberOfFilters = (int)filters.values().stream().filter(FullTextSearchFilter::isEnabled).count();
    setFiltersCounterLabelValue(numberOfFilters);
    saveFiltersCounterModelValue(numberOfFilters);
}

private void setFiltersCounterLabelValue(int numberOfFilters) {
    String numberOfFiltersAsString = String.valueOf(numberOfFilters);
    getFieldQueryFiltersCounterLabel().setValue(numberOfFiltersAsString);
}

private void saveFiltersCounterModelValue(int numberOfFilters) {
    getModel().setValue("filtersCounter", numberOfFilters);
}

@Override
protected boolean doSimpleSearch() {
    if (searchBox == null) {
        return false;
    } else {
        Optional<AdvancedSearchData> searchData = getSearchModel();
        if (searchData.isPresent()) {
            String query = StringUtils.defaultIfBlank(getSearchText(), "");
            setValue("simpleSearchTextQuery", query);
            AdvancedSearchData queryData = buildQueryData(query, searchData.get().getTypeCode());
            queryData.setTokenizable(true);

            Map<String, Set<String>> selectedFacets = new HashMap<>();

            Map<String, Set<String>> queryDataFacets = queryData.getSelectedFacets();
            if (queryDataFacets != null) {
                selectedFacets.putAll(queryDataFacets);
            }

            Map<String, Set<String>> rendererFacets = getDefaultFacetRenderer().getSelectedFacets();
            if (rendererFacets != null) {
                selectedFacets.putAll(rendererFacets);
            }

            queryData.setSelectedFacets(selectedFacets);

            queryData.setAdvancedSearchMode(AdvancedSearchMode.SIMPLE);
            queryData.setGlobalOperator(ValueComparisonOperator.OR);

            applyFilters(queryData);

            sendOutput("searchData", queryData);
            return true;
        } else {
            return false;
        }
    }
}

The most important line in the doSimpleSearch() method is the applyFilters(queryData):

private void applyFilters(AdvancedSearchData queryData) {
    List<SearchConditionData> conditions = getValue("fieldQueries", List.class);
    if (conditions != null) {
        conditions.forEach(condition -> queryData.addFilterQueryRawCondition(condition.getFieldType(),
                                                                                 condition.getOperator(),
                                                                                 condition.getValue()));
    }
}

The applyFilters(AdvancedSearchData queryData) method gets all the search conditions which were built in the buildSearchConditionData(Map<String, FullTextSearchFilter> filter) method and adds each one of them to the queryData variable. The queryData is then sent to the OOTB AdvancedSearchEngine which performs the search and returns the pageable result.

Now that we are ready with the search functionality, what is left to implement is what happens when changing the search type (e.g. if we go from one model listview to another) and what happens when switching the search mode (from advanced search to simple search and vice versa).

When changing the search type, we will need to reset all the applied filters. In order to do this, we will override the changeType(String typeCode) method. What it does is just removing the already existing filters:

@Override
@SocketEvent(socketId = "type")
public void changeType(String typeCode) {
    if (!typeCode.equals(ProductModel._TYPECODE)) {
        clearAppliedFilters();
        clearFieldQueryCounterLabel();
    }

    super.changeType(typeCode);
}

private void clearAppliedFilters() {
    setValue("fieldQueryFilters", Collections.emptyMap());
    setValue("fieldQueries", Collections.emptyList());
    doSearch();
}

private void clearFieldQueryCounterLabel() {
    getFieldQueryFiltersCounterLabel().setValue("0");
}

When changing the search type, we also need to update the “dataType” widget value which is used by the adjustFieldQuery() method above:

@Override
protected void performChangeType(String typeCode, boolean rootTypeChanged) {
    DataType dataType = loadDataTypeForCode(typeCode);
    if (dataType != null) {
        AdvancedSearch advancedSearch = loadAdvancedConfiguration(typeCode);
        AdvancedSearchData searchData = initOrLoadAdvancedSearchModel(advancedSearch, dataType);
        adjustWidgetModel(advancedSearch, searchData, rootTypeChanged, dataType);
        setActionSlotTypeCode(dataType.getCode());
        setValue("dataType", dataType);
    } else {
        setValue("searchModel", null);
        updateSearchMode(null);
        sendOutput("reset", null);
        getNotificationService().notifyUser(getNotificationSource(), "TypeChange", NotificationEvent.Level.FAILURE, new Object[]{typeCode});
    }
}

The last part of the controller implementation is the logic behind switching the search mode:

@Override
protected void updateSearchMode(AdvancedSearch config) {
    boolean simpleSearchVisible = shouldShowSimpleSearch(config);
    boolean simpleSearchDisabled = !isAttributesContainerCollapsed();
    boolean isCurrentTypeSearchable = isCurrentTypeSearchable();
    boolean simpleSearchDisabledByInitCtx = isSimpleSearchDisabledByInitCtx();
    setValue("simpleSearchModeActive", simpleSearchVisible && !simpleSearchDisabled && !simpleSearchDisabledByInitCtx);

    if (isCurrentTypeSearchable) {
        setSearchModeCaptionContainer(simpleSearchVisible, simpleSearchDisabled, simpleSearchDisabledByInitCtx);
        getAttributesGrid().setVisible(isDisplayInNonCollapsibleContainer() || simpleSearchDisabled || !simpleSearchVisible);
        updateOpenStateSClass(getAttributesGrid().isVisible());

        Optional<AdvancedSearchData> searchModel = getSearchModel();
        if (simpleSearchVisible && !simpleSearchDisabled && searchModel.isPresent() && searchModel.get().getTypeCode().equals(ProductModel._TYPECODE)) {
            getSearchModeCaptionContainer().appendChild(getFullTextSearchContainer());
            getFullTextSearchContainer().setVisible(true);
        } else {
            getFullTextSearchContainer().setVisible(false);
        }
    } else {
        Optional<AdvancedSearchData> advData = getSearchModel();
        boolean hasTypeSelected = advData.isPresent() && StringUtils.isNotBlank(((AdvancedSearchData)advData.get()).getTypeCode());
        if (hasTypeSelected) {
            getSearchTitle().setValue(getLabel("non.searchable.type", new Object[]{((AdvancedSearchData)advData.get()).getTypeCode()}));
        } else {
            getSearchTitle().setValue(getLabel("no.type.selected.info"));
        }

        getAttributesGrid().setVisible(false);
        getActionSlot().setVisible(false);
    }

    getSearchTitle().setVisible(!isCurrentTypeSearchable);
    getSearchModeCaptionContainer().setVisible(isCurrentTypeSearchable);
    getSearchButton().setVisible(isCurrentTypeSearchable);
    getSearchModeToggleButton().setVisible(isCurrentTypeSearchable && simpleSearchVisible);
}

Here we can also add a check for which types the sub-widget will be available, as shown above.

1.3. Update the definition.xml

1.3.1. Add the new controller class

After implementing the functionality of the controller, we need to link the controller to the widget itself. This is done by adding the following line to the definition.xml file (/backoffice/resources/widgets/customwidget/definition.xml):

<controller class="com.custom.backoffice.widgets.advancedsearch.CustomAdvancedSearchController"/>

1.3.2. Configure the settings

All the settings of the OOTB advanced search widget are preserved. The only sub-widget-specific setting which is added is the fulltextSearchConfigCtxCode:

<setting key="fulltextSearchConfigCtxCode" default-value="fulltext-search" type="String"/>

The value of this setting is responsible for the ID of the component in the -backoffice-config.xml files.

1.4. Add the labels

The labels which are sub-widget specific are as follow:

fieldquerybutton.tooltip=Filters
fieldquerypopup.title=Filters
fieldquerypopup.button.addfilter=Add more filters
fieldquerypopup.button.apply=Apply

Their localizations for the available languages can be added in the /backoffice/resources/widgets/customwidget/labels bundle.

2. Replace the OOTB AdvancedSearch widget with the custom one

Now that we have created our custom widget, we need to replace the OOTB widget with our custom one. The information about all available widgets and their position within the backoffice is stored in the -backoffice-widgets.xml files in all the backoffice extensions. The -backoffice-widgets.xml file is in the /resources folder (and not in the /backoffice/resources folder).

By default, the advanced search widget is situated within the collapsible container widget. Therefore, we will need to extend the collapsible container’s settings:

<widget-extension widgetId="collapsibleContainer">
    <remove widgetId="advancedSearch"/>
    <widget id="advancedSearch" widgetDefinitionId="com.novarto.backoffice.advancedsearch"
           slotId="center" template="false">
        ...
    </widget>
</widget-extension>

The ID of the new widget should be the same as the ID of the OOTB advanced search widget. The advantages of this approach are multiple, including:

  • Getting all backoffice configurations from the OOTB widget without having to set them explicitly
  • Using the already defined OOTB widget connections which define the communication channels between the widget to the existing widgets and actions. For example, the connection between the advanced search widget and the advanced search engine widget which is responsible for performing the actual search from within the backoffice and retrieving the Pageable result of the search.
  • Not having to add new widget connections with every newly introduced extension which has widgets or actions which communicate in some way with the advanced search widget

The widgetDefinitionId is retrieved from the definition.xml file.

3. Populate the sub-widget’s filter list

There are a couple of possible ways to populate the sub-widget’s filter list. The first way is by using the -backoffice-config.xml where we can list most of the -items.xml type attributes as well as SOLR indexed properties as filters. Another option is to create a custom configuration strategy which lists only those attributes/properties which meet specific requirements.

3.1. Using the -backoffice-config.xml

Populating the filters list via the -backoffice-config.xml is pretty straightforward – first we need to add the fulltextsearch namespace in the root tag of the XML file:

<config xmlns="http://www.hybris.com/cockpit/config"
        xmlns:ful="http://www.hybris.com/cockpitng/config/fulltextsearch">

Then we can go on defining the context – its component attribute should be equal to the fulltextSearchConfigCtxCode widget setting (part of the definition.xml). Its default value was set to fulltext-search in section 1.3.2. Configure the settings. We then need to point out the type for which this configuration applies and list the filters we need. For example:

<context type="Product" component="fulltext-search">
    <ful:fulltext-search>
        <ful:field-list>
            <ful:field name="code"/>
            <ful:field name="name"/>
        </ful:field-list>
        <ful:preferred-search-strategy>flexible</ful:preferred-search-strategy>
        <ful:operator>OR</ful:operator>
    </ful:fulltext-search>
</context>

The preferred-search-strategy tag has two possible values – flexible and solr. As their names imply, the first one uses flexible search, while the second uses the SOLR engine.

3.2. Using custom FullTextSearchConfigurationFallbackStrategy

If we want to determine the contents of the filters list on the go, we can create our custom FullTextSearchConfigurationFallbackStrategy. The available OOTB fallback strategy classes are: DefaultFulltextSearchConfigurationFallbackStrategy and SolrFullTextSearchConfigurationFallbackStrategy. The latter is part of the backofficesolrsearch extension and replaces the first one if it is enabled. 

When creating a custom fallback strategy, it is advisable for it to extend one of the already existing OOTB fallback strategies, e.g. the SolrFullTextSearchConfigurationFallbackStrategy.

The method which is responsible for building the filters list is the FulltextSearch loadFallbackConfiguration(ConfigContext context, Class<FulltextSearch> configurationType) method:

import com.hybris.backoffice.solrsearch.core.config.SolrFullTextSearchConfigurationFallbackStrategy;
import com.hybris.cockpitng.config.fulltextsearch.jaxb.FulltextSearch;
import com.hybris.cockpitng.core.config.ConfigContext;

public class CustomFullTextSearchConfigurationFallbackStrategy extends SolrFullTextSearchConfigurationFallbackStrategy {
   @Override
   public FulltextSearch loadFallbackConfiguration(ConfigContext context, Class<FulltextSearch> configurationType) {
       // CUSTOM IMPLEMENTATION GOES HERE
   }
}

Within the newly created fallback strategy, we can easily retrieve the available SOLR indexed properties via the FacetSearchConfig:

FacetSearchConfig searchConfig = super.getFacetSearchConfigService().getFacetSearchConfig(typeCode);

The typeCode of the current type we search for can be retrieved as follows:

String typeCode = super.getTypeFromContext(context);

After implementing the custom fallback strategy, the last step will be creating its Spring bean and replacing the OOTB strategy with the new one. For example:

<alias name="customSolrFullTextSearchConfigurationFallbackStrategy"
      alias="solrFullTextSearchConfigurationFallbackStrategy"/>
<bean name="customSolrFullTextSearchConfigurationFallbackStrategy" class="com.custom.backoffice.config.CustomFullTextSearchConfigurationFallbackStrategy"
     parent="defaultSolrFullTextSearchConfigurationFallbackStrategy"/>

Conclusion

In this blog post we covered how to incorporate more detailed search in the SAP Commerce Backoffice. We achieved it by extending the OOTB Advanced Search widget – we added the Simple Search and Filters widget to it and showed two different ways for populating the search criteria of the new sub-widget – by using the Backoffice configuration file and by using the SOLR engine.

Please feel free to share your questions and thoughts in the comment section below.

 

/
Fig.1.%20Simple%20Search%20and%20Filters%20Widget
Be the first to leave a comment
You must be Logged on to comment or reply to a post.