KSUP: How I Developed a Smart Search Across All System Entities

Published on
10 mins read
--- views

Task

To simplify user interaction with the platform and avoid constant switching between lists, it was necessary to develop a unified search page for all system entities. The search should support adaptive filters that would rebuild depending on the selected content type, support ranking logic, lazy loading of filter options, and different types.

Implementation

Development was carried out in close collaboration with another backend developer. Together, we designed the API structure and wrote brief documentation with request/response formats.

Source Code above is mutated for demonstration purposes

Content `Source Code` requires special access key

Please, communicate with administrator to obtain the access key here

All entities involved in the search were added to the SP search service, and the search service is essentially a wrapper that calls the search. The frontend was developed in ReactJS. A separate page was created, with a web part and a user control that embedded the React app JS bundle. Development was done using class components with TypeScript, bundler - Webpack, state management - Redux, state reading - reselect. For server side effects, redux-saga was chosen. The collection of all information from the filter view is handled by the Redux Forms library. The application area consists of a search bar, a sidebar with filter blocks, a results area, and a page with an extended set of filters. Each filter is described by a JSON structure that takes into account all business requirements for display, such as:

  • Linking the filter to the type of content being searched
  • Filter type
  • Order of the filter block
  • Order of filter options (with the ability to manually specify filters)
  • Belonging of the filter to quick access/all filters page

Input controls are based on the React-Widgets library, which was significantly improved in a forked repository, and several pull requests even made it into the official version! (for example, this one, but not this, this, or this). The filter configuration is presented in a JS file stored in the SharePoint library and can be changed and re-uploaded at any time (reconfiguration without rebuilding).

Content type element with meta-information

(identifiers changed)

{
  id: 0x0100E737C3D7E2C747AA8DD044E37D606A4E0088D43E65F6214A4A80534AC6302E0CEC*,
  title: Division,
  isActive: true,
    resultType:20,
    customer:KsupEgrCustomer,
    legal:KsupEgrPerformerLegal,
    workServicesType:KsupWorkServicesTypeMetadata,
    keyTechnologies:KsupKeyTechnologies,
    divisionType:KsupDivisionType
  ],
  displayFields: [
    Title:Title,
    KsupProjectCount:Number of projects,
    KsupDivisionType:Division type
  ]
},

Filter with values loaded from list

{
  title: Customer,
  name: customer,
  type: list,
  quick: true,
  loadable: true,
  props: {
    contentTypeId: 0x0100E737C3D7E2C747AA8DD044E37D606A4E00212934FEC6AC4A49AC16BE42A2CDE947*,
    values: [],
    sortBy: KsupProjectCount,
    sortDirection: desc
  }
},

Filter with values loaded from termStore

{
  title: Work and Service Type,
  name: workServicesType,
  type: term,
  quick: true,
  loadable: true,
  props: {
    termName: Work and Service Type,
    values: [],
    sortBy: alphabet,
    sortDirection: asc
  }
}

Filter with values loaded from enum field

(identifiers changed)

{
  title: Contract Status,
  name: contractStatus,
  type: choice,
  quick: true,
  loadable: true,
  props: {
    webId: f42d8549-dec9-4e17-99be-f58d0fd2be82,
    listId: 1807af46-fc0a-44b3-aac4-fd14bc98e758,
    fieldId: 8086a24f-522a-4af4-9e63-fe8ce7431be6,
    values: []
  }
}

Filter with hardcoded choice values

(identifiers changed)

{
  title: ,
  name: legalType,
  type: choice,
  quick: false,
  loadable: false,
  props: {
    values: [
      {
        label: Legal Entity,
        value: 0x0100E737C3D7E2C747AA8DD044E37D606A4E00212934FE36AC4A49AC16BE42A2CDE94700D537C55254C948E9BA72A2DDAA7431E1
      },
      {
        label: Individual Entrepreneur,
        value: 0x0100E737C3D7E2C747AA8DD044E37D606A4E00212934FEC6AC4A49AC16BE42A2CDE9470097D8FB3C785A4EE58A4E14B870C58024
      }
    ]
  }
},

An abstract component is used to handle filters depending on their types

Content `Source Code` requires special access key

Please, communicate with administrator to obtain the access key here

Example saga + watcher

Content `Source Code` requires special access key

Please, communicate with administrator to obtain the access key here

Result selector returning filter values with business logic (can be chained with other selectors)

getCurrentFiltersWithValues
export const getCurrentFiltersWithValues = createSelector<
  Ifaces.IknowledgeBaseSearchContext,
  Ifaces.IResolvedFilter[],
  Ifaces.IFilterSetValue[],
  Ifaces.IFilterSetValue[],
  Ifaces.IResolvedFilter[]
>(
  (state: Ifaces.IknowledgeBaseSearchContext) => getCurrentFilters(state),
  (state: Ifaces.IknowledgeBaseSearchContext) => getInitialFilterValues(state),
  (state: Ifaces.IknowledgeBaseSearchContext) => getAllListFilterValues(state),
  (currentFilters, initialFilterValues, listFilterValues) =>
    _(currentFilters)
      .map((filter) => {
        const {
          filter: {
            type,
            name: filterName,
            props: { values },
          },
        } = filter;

// if the filter configuration has constant values
// then keep them
        if (values.length > 0) {
          return filter;
        }

        let foundValues: ISelectOption[] = [];
        let pageInfo: IPageInfo = pageInfoInitialFilters;

        if (
          type === Enums.FilterTypeEnum.list ||
          type === Enums.FilterTypeEnum.term
        ) {
          let foundDict = getListFilterValues(filterName, listFilterValues);

          foundDict = !foundDict
            ? getInitialFilterValue(filter, initialFilterValues)
            : foundDict;

          foundValues = foundDict ? foundDict.values : [];
          pageInfo = foundDict ? foundDict.pageInfo : pageInfoInitialFilters;
        } else {
          let foundFilterSet = getInitialFilterValue(
            filter,
            initialFilterValues
          );

          foundValues = foundFilterSet ? foundFilterSet.values : [];
          pageInfo = foundFilterSet
            ? foundFilterSet.pageInfo
            : pageInfoInitialFilters;
        }

        let cpy = _.cloneDeep(filter);
        cpy.filter.props.values = foundValues;
        cpy.filter.props.pageInfo = pageInfo;
        return cpy;
      })
      .orderBy(x => Math.abs(x.priority), desc)
      .value()
);

Test coverage code

Jest
import _ from lodash;
import {
  loadResultTypesFilterValues,
  loadFiltersConfig,
  loadInitialFilters,
  loadListFilterValues,
} from ./api;
import { IFilter } from ./interfaces;

it(loadResultTypesFilterValues - for resultType filter successed, () => {
  var filtersConfig = loadFiltersConfig();

  const resultTypes = loadResultTypesFilterValues(filtersConfig);
  expect(resultTypes).toHaveLength(5);
});

it(getFilterValuesFromList - for customer list filter success, () => {
  var filtersConfig = loadFiltersConfig();
  var customerFilter = _.find(
    filtersConfig.filters,
    (x: IFilter) => x.name === customer
  );

  expect(customerFilter).not.toBeNull();

  if (customerFilter) {
    loadListFilterValues(customerFilter).then((response: any) => {
      expect(response).toHaveLength(5);
    });
  }

});

it(getFilterSetValues - for contractStatus (enum) and workServiceType (term) success, () => {
var filtersConfig = loadFiltersConfig();

  var contractStatusFilter = _.find(
    filtersConfig.filters,
    (x: IFilter) => x.name === contractStatus
  );
  var workServiceType = _.find(
    filtersConfig.filters,
    (x: IFilter) => x.name === workServicesType
  );

  if (contractStatusFilter && workServiceType) {
    loadInitialFilters(
      [contractStatusFilter, workServiceType],
      filtersConfig
    ).then((response: any) => {
      expect(response).toHaveLength(2);
      _.each(response, (x) => {
        const { values } = x;
        expect(values).not.toHaveLength(0);
        expect(typeof values).toEqual(object);
      });
    });
  }

});