Skip to content

Commit

Permalink
feat(headless): exposes category facet values hierarchically (#3105)
Browse files Browse the repository at this point in the history
* feat: exposes values hierarchicaly

https://coveord.atlassian.net/browse/KIT-2668

* typo in ut

https://coveord.atlassian.net/browse/KIT-2668

* fixed tests

https://coveord.atlassian.net/browse/KIT-2668

* Revert "fixed tests"

This reverts commit 30026fb.

* adjust behavior around more/less value

https://coveord.atlassian.net/browse/KIT-2668

* remove internal uses of `values`

https://coveord.atlassian.net/browse/KIT-2668

* probably fix e2e

https://coveord.atlassian.net/browse/KIT-2668

* refactor getActiveValueFromValueTree

https://coveord.atlassian.net/browse/KIT-2668

* getActiveValueFromValueTree: prioritize first value in the array

https://coveord.atlassian.net/browse/KIT-2668

* unit tests

https://coveord.atlassian.net/browse/KIT-2668

* improve comments

https://coveord.atlassian.net/browse/KIT-2668
  • Loading branch information
louis-bompart authored Aug 24, 2023
1 parent b7b78b3 commit 4b6b0af
Show file tree
Hide file tree
Showing 4 changed files with 231 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
} from '../../../../features/facets/category-facet-set/category-facet-set-actions';
import {categoryFacetSetReducer as categoryFacetSet} from '../../../../features/facets/category-facet-set/category-facet-set-slice';
import {defaultCategoryFacetOptions} from '../../../../features/facets/category-facet-set/category-facet-set-slice';
import {
getActiveValueFromValueTree,
partitionIntoParentsAndValues,
} from '../../../../features/facets/category-facet-set/category-facet-utils';
import {
CategoryFacetRequest,
CategoryFacetSortCriterion,
Expand All @@ -31,15 +35,33 @@ import * as FacetIdDeterminor from '../_common/facet-id-determinor';
import {
buildCoreCategoryFacet,
CategoryFacetOptions,
CategoryFacetValue,
CoreCategoryFacet,
} from './headless-core-category-facet';

jest.mock(
'../../../../features/facets/category-facet-set/category-facet-utils'
);

const {
getActiveValueFromValueTree: actualGetActiveValueFromValueTree,
partitionIntoParentsAndValues: actualPartitionIntoParentsAndValues,
} = jest.requireActual(
'../../../../features/facets/category-facet-set/category-facet-utils'
);

describe('category facet', () => {
const facetId = '1';
let options: CategoryFacetOptions;
let state: SearchAppState;
let engine: MockSearchEngine;
let categoryFacet: CoreCategoryFacet;
const getActiveValueFromValueTreeMock = jest.mocked(
getActiveValueFromValueTree
);
const partitionIntoParentsAndValuesMock = jest.mocked(
partitionIntoParentsAndValues
);

function initCategoryFacet() {
engine = buildMockSearchAppEngine({state});
Expand All @@ -57,7 +79,12 @@ describe('category facet', () => {
facetId,
field: 'geography',
};

getActiveValueFromValueTreeMock.mockImplementation(
actualGetActiveValueFromValueTree
);
partitionIntoParentsAndValuesMock.mockImplementation(
actualPartitionIntoParentsAndValues
);
state = createMockState();
setFacetRequest();
initCategoryFacet();
Expand Down Expand Up @@ -108,6 +135,16 @@ describe('category facet', () => {
expect(categoryFacet.subscribe).toBeDefined();
});

it('#state.activeValue is the return value of #getActiveValueFromValueTree', () => {
const referencedReturnValue = buildMockCategoryFacetValue();
getActiveValueFromValueTreeMock.mockReturnValueOnce(referencedReturnValue);

initCategoryFacet();

expect(getActiveValueFromValueTree).toBeCalledTimes(1);
expect(categoryFacet.state.activeValue).toBe(referencedReturnValue);
});

describe('when the search response is empty', () => {
it('#state.values is an empty array', () => {
expect(state.search.response.facets).toEqual([]);
Expand All @@ -117,15 +154,34 @@ describe('category facet', () => {
it('#state.parents is an empty array', () => {
expect(categoryFacet.state.parents).toEqual([]);
});

it('#state.valuesAsTrees', () => {
expect(categoryFacet.state.valuesAsTrees).toEqual([]);
});
});

it(`when the search response has a category facet with a single level of values,
#state.values contains the same values`, () => {
describe('when the search response has a category facet with a single level of values', () => {
const values = [buildMockCategoryFacetValue()];
const response = buildMockCategoryFacetResponse({facetId, values});

beforeEach(() => {
const response = buildMockCategoryFacetResponse({facetId, values});
state.search.response.facets = [response];
});

it('#state.values contains the same values', () => {
expect(categoryFacet.state.values).toBe(values);
});

it('#state.valuesAsTrees contains the same values', () => {
expect(categoryFacet.state.valuesAsTrees).toBe(values);
});
});

it('#state.valuesAsTrees is the untouched response', () => {
const values: CategoryFacetValue[] = [];
const response = buildMockCategoryFacetResponse({facetId, values});
state.search.response.facets = [response];
expect(categoryFacet.state.values).toBe(values);
expect(categoryFacet.state.valuesAsTrees).toBe(values);
});

describe('when the search response has a category facet with nested values', () => {
Expand Down Expand Up @@ -161,6 +217,49 @@ describe('category facet', () => {
it('#state.parents contains the outer and middle values', () => {
expect(categoryFacet.state.parents).toEqual([outerValue, middleValue]);
});

it('#state.valueAsTree contains the outer value', () => {
expect(categoryFacet.state.valuesAsTrees).toEqual([outerValue]);
});

it('#state.isHierarchical should be true', () => {
expect(categoryFacet.state.isHierarchical).toBe(true);
});
});

describe('when the search response has a category facet with nested values and multiple root values', () => {
const innerValues = [
buildMockCategoryFacetValue({value: 'C'}),
buildMockCategoryFacetValue({value: 'D'}),
];
const middleValue = buildMockCategoryFacetValue({
value: 'B',
children: innerValues,
});
const outerValue = buildMockCategoryFacetValue({
value: 'A',
children: [middleValue],
});
const neighboringValue = buildMockCategoryFacetValue({value: 'D'});

beforeEach(() => {
const response = buildMockCategoryFacetResponse({
facetId,
values: [outerValue, neighboringValue],
});
state.search.response.facets = [response];
});

it('#state.valuesAsTrees contains both root values (outer & neighboring)', () => {
expect(categoryFacet.state.valuesAsTrees).toEqual([
outerValue,
neighboringValue,
]);
});

it('#state.isHierarchical should be true', () => {
expect(categoryFacet.state.isHierarchical).toBe(true);
});
});

describe('when the category facet has a selected leaf value with no children', () => {
Expand All @@ -185,6 +284,14 @@ describe('category facet', () => {
it('#state.values is an empty array', () => {
expect(categoryFacet.state.values).toEqual([]);
});

it('#state.activeValue is the selected leaf value', () => {
expect(categoryFacet.state.activeValue).toEqual(selectedValue);
});

it('#state.hasActiveValues is true', () => {
expect(categoryFacet.state.hasActiveValues).toBe(true);
});
});

describe('#toggleSelect', () => {
Expand Down Expand Up @@ -295,7 +402,7 @@ describe('category facet', () => {
expect(categoryFacet.state.canShowMoreValues).toBe(true);
});

it('if #moreValuesAvailable is true, #state.canShowMore is true', () => {
it('if #moreValuesAvailable is false, #state.canShowMore is false', () => {
const values = [
buildMockCategoryFacetValue({
numberOfResults: 10,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {categoryFacetResponseSelector} from '../../../../features/facets/categor
import {categoryFacetRequestSelector} from '../../../../features/facets/category-facet-set/category-facet-set-selectors';
import {defaultCategoryFacetOptions} from '../../../../features/facets/category-facet-set/category-facet-set-slice';
import {categoryFacetSetReducer as categoryFacetSet} from '../../../../features/facets/category-facet-set/category-facet-set-slice';
import {partitionIntoParentsAndValues} from '../../../../features/facets/category-facet-set/category-facet-utils';
import {
getActiveValueFromValueTree,
partitionIntoParentsAndValues,
} from '../../../../features/facets/category-facet-set/category-facet-utils';
import {CategoryFacetSortCriterion} from '../../../../features/facets/category-facet-set/interfaces/request';
import {CategoryFacetValue} from '../../../../features/facets/category-facet-set/interfaces/response';
import {categoryFacetSearchSetReducer as categoryFacetSearchSet} from '../../../../features/facets/facet-search-set/category/category-facet-search-set-slice';
Expand Down Expand Up @@ -141,10 +144,34 @@ export interface CoreCategoryFacetState {
/** The facet ID. */
facetId: string;

/** The facet's parent values. */
/**
* The root facet values.
* Child values might be available in `valuesAsTrees[i].children[j]`
* @example `{value: 'foo' }
*/
valuesAsTrees: CategoryFacetValue[];

/**
* The facet's selected value if any, undefined otherwise.
*/
activeValue: CategoryFacetValue | undefined;

/**
* Whether `valuesAsTree` contains hierarchical values (i.e. facet values with children), or only 'flat' values (i.e. facet values without children).
*/
isHierarchical: boolean;

/**
* The facet's parent values.
* @deprecated uses `valuesAsTrees` instead.
*
*/
parents: CategoryFacetValue[];

/** The facet's values. */
/**
* The facet's values.
* @deprecated use `valuesAsTrees` instead.
*/
values: CategoryFacetValue[];

/** The facet's active `sortCriterion`. */
Expand Down Expand Up @@ -326,8 +353,9 @@ export function buildCoreCategoryFacet(

showMoreValues() {
const {numberOfValues: increment} = options;
const {values} = this.state;
const numberOfValues = values.length + increment;
const {activeValue, valuesAsTrees} = this.state;
const numberOfValues =
(activeValue?.children.length ?? valuesAsTrees.length) + increment;

dispatch(updateCategoryFacetNumberOfValues({facetId, numberOfValues}));
dispatch(updateFacetOptions());
Expand All @@ -353,19 +381,27 @@ export function buildCoreCategoryFacet(
const response = getResponse();
const isLoading = getIsLoading();
const enabled = getIsEnabled();

const valuesAsTrees = response?.values ?? [];
const isHierarchical =
valuesAsTrees.some((value) => value.children.length > 0) ?? false;
const {parents, values} = partitionIntoParentsAndValues(response?.values);
const hasActiveValues = parents.length !== 0;
const activeValue = getActiveValueFromValueTree(valuesAsTrees);
const hasActiveValues = !!activeValue;
const canShowMoreValues =
parents.length > 0
? parents[parents.length - 1].moreValuesAvailable
: response?.moreValuesAvailable || false;
const canShowLessValues = values.length > options.numberOfValues;
activeValue?.moreValuesAvailable ??
response?.moreValuesAvailable ??
false;
const canShowLessValues = activeValue
? activeValue.children.length > options.numberOfValues
: valuesAsTrees.length > options.numberOfValues;

return {
facetId,
parents,
values,
isHierarchical,
valuesAsTrees,
activeValue,
isLoading,
hasActiveValues,
canShowMoreValues,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {buildMockCategoryFacetValue} from '../../../test/mock-category-facet-value';
import {getActiveValueFromValueTree} from './category-facet-utils';
import {CategoryFacetValue} from './interfaces/response';

describe('#getActiveValueFromValueTree', () => {
it("should return undefined if there's no selected values", () => {
expect(getActiveValueFromValueTree([])).toBeUndefined();
});

it.each([
{
caseName: 'doubleRootValues',
getValues: (expectedValue: CategoryFacetValue) => [
expectedValue,
buildMockCategoryFacetValue({state: 'selected'}),
],
},
{
caseName: 'rootAndNestedValues',
getValues: (expectedValue: CategoryFacetValue) => [
buildMockCategoryFacetValue({children: [expectedValue]}),
buildMockCategoryFacetValue({state: 'selected'}),
],
},
{
caseName: 'doubleNestedValues',
getValues: (expectedValue: CategoryFacetValue) => [
buildMockCategoryFacetValue({children: [expectedValue]}),
buildMockCategoryFacetValue({
children: [buildMockCategoryFacetValue({state: 'selected'})],
}),
],
},
{
caseName: 'singleNestedValue',
getValues: (expectedValue: CategoryFacetValue) => [
buildMockCategoryFacetValue({children: [expectedValue]}),
],
},
{
caseName: 'singleRootValue',
getValues: (expectedValue: CategoryFacetValue) => [expectedValue],
},
])(
'should return the first selected values found while doing a depth first search - $caseName',
({getValues}) => {
const expectedValues = buildMockCategoryFacetValue({
state: 'selected',
value: 'A',
});

const values: CategoryFacetValue[] = getValues(expectedValues);

expect(getActiveValueFromValueTree(values)).toBe(expectedValues);
}
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,17 @@ export function partitionIntoParentsAndValues<

return {parents, values};
}

export function getActiveValueFromValueTree(
valuesAsTrees: CategoryFacetValue[]
): CategoryFacetValue | undefined {
const valueToVisit = [...valuesAsTrees];
while (valueToVisit.length > 0) {
const currentValue = valueToVisit.shift()!;
if (currentValue.state === 'selected') {
return currentValue;
}
valueToVisit.unshift(...currentValue.children);
}
return undefined;
}

0 comments on commit 4b6b0af

Please sign in to comment.