[App Search] Wired up configurable Sort and Facets in Documents View (#88764)

This commit is contained in:
Jason Stoltzfus 2021-01-20 14:57:37 -05:00 committed by GitHub
parent f4f6cb687c
commit 15f05b51ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 524 additions and 52 deletions

View file

@ -15,24 +15,49 @@ describe('buildSearchUIConfig', () => {
foo: 'text' as SchemaTypes,
bar: 'number' as SchemaTypes,
};
const fields = {
filterFields: ['fieldA', 'fieldB'],
sortFields: [],
};
const config = buildSearchUIConfig(connector, schema);
expect(config.apiConnector).toEqual(connector);
expect(config.searchQuery.result_fields).toEqual({
bar: {
raw: {},
snippet: {
fallback: true,
size: 300,
},
},
foo: {
raw: {},
snippet: {
fallback: true,
size: 300,
const config = buildSearchUIConfig(connector, schema, fields);
expect(config).toEqual({
alwaysSearchOnInitialLoad: true,
apiConnector: connector,
initialState: {
sortDirection: 'desc',
sortField: 'id',
},
searchQuery: {
disjunctiveFacets: ['fieldA', 'fieldB'],
facets: {
fieldA: {
size: 30,
type: 'value',
},
fieldB: {
size: 30,
type: 'value',
},
},
result_fields: {
bar: {
raw: {},
snippet: {
fallback: true,
size: 300,
},
},
foo: {
raw: {},
snippet: {
fallback: true,
size: 300,
},
},
},
},
trackUrlState: false,
});
});
});

View file

@ -5,8 +5,17 @@
*/
import { Schema } from '../../../../shared/types';
import { Fields } from './types';
export const buildSearchUIConfig = (apiConnector: object, schema: Schema, fields: Fields) => {
const facets = fields.filterFields.reduce(
(facetsConfig, fieldName) => ({
...facetsConfig,
[fieldName]: { type: 'value', size: 30 },
}),
{}
);
export const buildSearchUIConfig = (apiConnector: object, schema: Schema) => {
return {
alwaysSearchOnInitialLoad: true,
apiConnector,
@ -16,6 +25,8 @@ export const buildSearchUIConfig = (apiConnector: object, schema: Schema) => {
sortField: 'id',
},
searchQuery: {
disjunctiveFacets: fields.filterFields,
facets,
result_fields: Object.keys(schema).reduce((acc: { [key: string]: object }, key: string) => {
acc[key] = {
snippet: {

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { buildSortOptions } from './build_sort_options';
describe('buildSortOptions', () => {
it('builds sort options from a list of field names', () => {
const sortOptions = buildSortOptions(
{
filterFields: [],
sortFields: ['fieldA', 'fieldB'],
},
[
{
name: 'Relevance (asc)',
value: 'id',
direction: 'desc',
},
{
name: 'Relevance (desc)',
value: 'id',
direction: 'asc',
},
]
);
expect(sortOptions).toEqual([
{
name: 'Relevance (asc)',
value: 'id',
direction: 'desc',
},
{
name: 'Relevance (desc)',
value: 'id',
direction: 'asc',
},
{
direction: 'asc',
name: 'fieldA (asc)',
value: 'fieldA',
},
{
direction: 'desc',
name: 'fieldA (desc)',
value: 'fieldA',
},
{
direction: 'asc',
name: 'fieldB (asc)',
value: 'fieldB',
},
{
direction: 'desc',
name: 'fieldB (desc)',
value: 'fieldB',
},
]);
});
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { flatten } from 'lodash';
import { Fields, SortOption, SortDirection } from './types';
import { ASCENDING, DESCENDING } from './constants';
const fieldNameToSortOptions = (fieldName: string): SortOption[] =>
['asc', 'desc'].map((direction) => ({
name: direction === 'asc' ? ASCENDING(fieldName) : DESCENDING(fieldName),
value: fieldName,
direction: direction as SortDirection,
}));
/**
* Adds two sort options for a given field, a "desc" and an "asc" option.
*/
export const buildSortOptions = (
fields: Fields,
defaultSortOptions: SortOption[]
): SortOption[] => {
const sortFieldsOptions = flatten(fields.sortFields.map(fieldNameToSortOptions));
const sortingOptions = [...defaultSortOptions, ...sortFieldsOptions];
return sortingOptions;
};

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const ASCENDING = (fieldName: string) =>
i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.ascendingDropDownOptionLabel',
{
defaultMessage: '{fieldName} (asc)',
values: { fieldName },
}
);
export const DESCENDING = (fieldName: string) =>
i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.descendingDropDownOptionLabel',
{
defaultMessage: '{fieldName} (desc)',
values: { fieldName },
}
);

View file

@ -25,4 +25,12 @@
background-color: $euiPageBackgroundColor;
padding: $euiSizeL;
}
.documentsSearchExperience__facet {
line-height: 0;
.euiCheckbox__label {
@include euiTextTruncate;
}
}
}

View file

@ -7,25 +7,19 @@
import '../../../../__mocks__/enterprise_search_url.mock';
import { setMockValues } from '../../../../__mocks__';
const mockSetFields = jest.fn();
jest.mock('../../../../shared/use_local_storage', () => ({
useLocalStorage: jest.fn(() => [
{
filterFields: ['a', 'b', 'c'],
sortFields: ['d', 'c'],
},
mockSetFields,
]),
useLocalStorage: jest.fn(),
}));
import { useLocalStorage } from '../../../../shared/use_local_storage';
import React from 'react';
// @ts-expect-error types are not available for this package yet
import { SearchProvider } from '@elastic/react-search-ui';
import { shallow } from 'enzyme';
import { SearchProvider, Facet } from '@elastic/react-search-ui';
import { shallow, ShallowWrapper } from 'enzyme';
import { CustomizationCallout } from './customization_callout';
import { CustomizationModal } from './customization_modal';
import { Fields } from './types';
import { SearchExperience } from './search_experience';
@ -36,8 +30,16 @@ describe('SearchExperience', () => {
apiKey: '1234',
},
};
const mockSetFields = jest.fn();
const setFieldsInLocalStorage = (fields: Fields) => {
(useLocalStorage as jest.Mock).mockImplementation(() => [fields, mockSetFields]);
};
beforeEach(() => {
setFieldsInLocalStorage({
filterFields: ['a', 'b', 'c'],
sortFields: ['d', 'c'],
});
jest.clearAllMocks();
setMockValues(values);
});
@ -47,12 +49,60 @@ describe('SearchExperience', () => {
expect(wrapper.find(SearchProvider).length).toBe(1);
});
describe('when there are no selected filter fields', () => {
let wrapper: ShallowWrapper;
beforeEach(() => {
setFieldsInLocalStorage({
filterFields: [],
sortFields: ['a', 'b'],
});
wrapper = shallow(<SearchExperience />);
});
it('shows a customize callout instead of a button if no fields are yet selected', () => {
expect(wrapper.find(CustomizationCallout).exists()).toBe(true);
expect(wrapper.find('[data-test-subj="customize"]').exists()).toBe(false);
});
it('will show the customization modal when clicked', () => {
expect(wrapper.find(CustomizationModal).exists()).toBe(false);
wrapper.find(CustomizationCallout).simulate('click');
expect(wrapper.find(CustomizationModal).exists()).toBe(true);
});
});
describe('when there are selected filter fields', () => {
let wrapper: ShallowWrapper;
beforeEach(() => {
setFieldsInLocalStorage({
filterFields: ['a', 'b'],
sortFields: ['a', 'b'],
});
wrapper = shallow(<SearchExperience />);
});
it('shows a customize button', () => {
expect(wrapper.find(CustomizationCallout).exists()).toBe(false);
expect(wrapper.find('[data-test-subj="customize"]').exists()).toBe(true);
});
});
it('renders Facet components for filter fields', () => {
setFieldsInLocalStorage({
filterFields: ['a', 'b', 'c'],
sortFields: [],
});
const wrapper = shallow(<SearchExperience />);
expect(wrapper.find(Facet).length).toBe(3);
});
describe('customization modal', () => {
it('has a customization modal which can be opened and closed', () => {
const wrapper = shallow(<SearchExperience />);
expect(wrapper.find(CustomizationModal).exists()).toBe(false);
wrapper.find(CustomizationCallout).simulate('click');
wrapper.find('[data-test-subj="customize"]').simulate('click');
expect(wrapper.find(CustomizationModal).exists()).toBe(true);
wrapper.find(CustomizationModal).prop('onClose')();
@ -61,14 +111,14 @@ describe('SearchExperience', () => {
it('passes values from localStorage to the customization modal', () => {
const wrapper = shallow(<SearchExperience />);
wrapper.find(CustomizationCallout).simulate('click');
wrapper.find('[data-test-subj="customize"]').simulate('click');
expect(wrapper.find(CustomizationModal).prop('filterFields')).toEqual(['a', 'b', 'c']);
expect(wrapper.find(CustomizationModal).prop('sortFields')).toEqual(['d', 'c']);
});
it('updates selected fields in localStorage and closes modal on save', () => {
const wrapper = shallow(<SearchExperience />);
wrapper.find(CustomizationCallout).simulate('click');
wrapper.find('[data-test-subj="customize"]').simulate('click');
wrapper.find(CustomizationModal).prop('onSave')({
filterFields: ['new', 'filters'],
sortFields: ['new', 'sorts'],

View file

@ -7,36 +7,41 @@ import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { useValues } from 'kea';
import { EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiButton, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
// @ts-expect-error types are not available for this package yet;
import { SearchProvider, SearchBox, Sorting } from '@elastic/react-search-ui';
import { SearchProvider, SearchBox, Sorting, Facet } from '@elastic/react-search-ui';
// @ts-expect-error types are not available for this package yet
import AppSearchAPIConnector from '@elastic/search-ui-app-search-connector';
import './search_experience.scss';
import { EngineLogic } from '../../engine';
import { externalUrl } from '../../../../shared/enterprise_search_url';
import { useLocalStorage } from '../../../../shared/use_local_storage';
import { EngineLogic } from '../../engine';
import { SearchBoxView, SortingView } from './views';
import { Fields, SortOption } from './types';
import { SearchBoxView, SortingView, MultiCheckboxFacetsView } from './views';
import { SearchExperienceContent } from './search_experience_content';
import { buildSearchUIConfig } from './build_search_ui_config';
import { CustomizationCallout } from './customization_callout';
import { CustomizationModal } from './customization_modal';
import { buildSortOptions } from './build_sort_options';
import { ASCENDING, DESCENDING } from './constants';
const DEFAULT_SORT_OPTIONS = [
const RECENTLY_UPLOADED = i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded',
{
name: i18n.translate('xpack.enterpriseSearch.appSearch.documents.search.recentlyUploadedDesc', {
defaultMessage: 'Recently Uploaded (desc)',
}),
defaultMessage: 'Recently Uploaded',
}
);
const DEFAULT_SORT_OPTIONS: SortOption[] = [
{
name: DESCENDING(RECENTLY_UPLOADED),
value: 'id',
direction: 'desc',
},
{
name: i18n.translate('xpack.enterpriseSearch.appSearch.documents.search.recentlyUploadedAsc', {
defaultMessage: 'Recently Uploaded (asc)',
}),
name: ASCENDING(RECENTLY_UPLOADED),
value: 'id',
direction: 'asc',
},
@ -50,16 +55,15 @@ export const SearchExperience: React.FC = () => {
const openCustomizationModal = () => setShowCustomizationModal(true);
const closeCustomizationModal = () => setShowCustomizationModal(false);
const [fields, setFields] = useLocalStorage(
const [fields, setFields] = useLocalStorage<Fields>(
`documents-search-experience-customization--${engine.name}`,
{
filterFields: [] as string[],
sortFields: [] as string[],
filterFields: [],
sortFields: [],
}
);
// TODO const sortFieldsOptions = _flatten(fields.sortFields.map(fieldNameToSortOptions)) // we need to flatten this array since fieldNameToSortOptions returns an array of two sorting options
const sortingOptions = [...DEFAULT_SORT_OPTIONS /* TODO ...sortFieldsOptions*/];
const sortingOptions = buildSortOptions(fields, DEFAULT_SORT_OPTIONS);
const connector = new AppSearchAPIConnector({
cacheResponses: false,
@ -68,7 +72,7 @@ export const SearchExperience: React.FC = () => {
searchKey: engine.apiKey,
});
const searchProviderConfig = buildSearchUIConfig(connector, engine.schema || {});
const searchProviderConfig = buildSearchUIConfig(connector, engine.schema || {}, fields);
return (
<div className="documentsSearchExperience">
@ -101,7 +105,36 @@ export const SearchExperience: React.FC = () => {
view={SortingView}
/>
<EuiSpacer />
<CustomizationCallout onClick={openCustomizationModal} />
{fields.filterFields.length > 0 ? (
<>
{fields.filterFields.map((fieldName) => (
<section key={fieldName}>
<Facet
field={fieldName}
label={fieldName}
view={MultiCheckboxFacetsView}
filterType="any"
/>
<EuiSpacer size="l" />
</section>
))}
<EuiButton
data-test-subj="customize"
color="primary"
iconType="gear"
onClick={openCustomizationModal}
>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.customizationButton',
{
defaultMessage: 'Customize filters and sort',
}
)}
</EuiButton>
</>
) : (
<CustomizationCallout onClick={openCustomizationModal} />
)}
</EuiFlexItem>
<EuiFlexItem className="documentsSearchExperience__content">
<SearchExperienceContent />

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface Fields {
filterFields: string[];
sortFields: string[];
}
export type SortDirection = 'asc' | 'desc';
export interface SortOption {
name: string;
value: string;
direction: SortDirection;
}

View file

@ -9,3 +9,4 @@ export { SortingView } from './sorting_view';
export { ResultView } from './result_view';
export { ResultsPerPageView } from './results_per_page_view';
export { PagingView } from './paging_view';
export { MultiCheckboxFacetsView } from './multi_checkbox_facets_view';

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { MultiCheckboxFacetsView } from './multi_checkbox_facets_view';
describe('MultiCheckboxFacetsView', () => {
const props = {
label: 'foo',
options: [
{
value: 'value1',
selected: false,
},
{
value: 'value2',
selected: false,
},
],
showMore: true,
onMoreClick: jest.fn(),
onRemove: jest.fn(),
onSelect: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders', () => {
const wrapper = shallow(<MultiCheckboxFacetsView {...props} />);
expect(wrapper.isEmptyRender()).toBe(false);
});
it('calls onMoreClick when more button is clicked', () => {
const wrapper = shallow(<MultiCheckboxFacetsView {...props} />);
wrapper.find('[data-test-subj="more"]').simulate('click');
expect(props.onMoreClick).toHaveBeenCalled();
});
it('calls onSelect when an option is selected', () => {
const wrapper = shallow(<MultiCheckboxFacetsView {...props} />);
wrapper.find('[data-test-subj="checkbox-group"]').simulate('change', 'generated-id_1');
expect(props.onSelect).toHaveBeenCalledWith('value2');
});
it('calls onRemove if the option was already selected', () => {
const wrapper = shallow(
<MultiCheckboxFacetsView
{...{
...props,
options: [
{
value: 'value1',
selected: false,
},
{
value: 'value2',
selected: true,
},
],
}}
/>
);
wrapper.find('[data-test-subj="checkbox-group"]').simulate('change', 'generated-id_1');
expect(props.onRemove).toHaveBeenCalledWith('value2');
});
it('it passes options to EuiCheckboxGroup, converting no values to the text "No Value"', () => {
const wrapper = shallow(
<MultiCheckboxFacetsView
{...{
...props,
options: [
{
value: 'value1',
selected: true,
},
{
value: '',
selected: false,
},
],
}}
/>
);
const options = wrapper.find('[data-test-subj="checkbox-group"]').prop('options');
expect(options).toEqual([
{ id: 'generated-id_0', label: 'value1' },
{ id: 'generated-id_1', label: '<No value>' },
]);
});
});

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
htmlIdGenerator,
EuiCheckboxGroup,
EuiFlexGroup,
EuiButtonEmpty,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface Option {
value: string;
selected: boolean;
}
interface Props {
label: string;
options: Option[];
showMore: boolean;
onMoreClick(): void;
onRemove(id: string): void;
onSelect(id: string): void;
}
const getIndexFromId = (id: string) => parseInt(id.split('_')[1], 10);
export const MultiCheckboxFacetsView: React.FC<Props> = ({
label,
onMoreClick,
onRemove,
onSelect,
options,
showMore,
}) => {
const getId = htmlIdGenerator();
const optionToCheckBoxGroupOption = (option: Option, index: number) => ({
id: getId(String(index)),
label:
option.value ||
i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.multiCheckboxFacetsView.noValue.selectOption',
{
defaultMessage: '<No value>',
}
),
});
const optionToSelectedMapReducer = (
selectedMap: { [name: string]: boolean },
option: Option,
index: number
) => {
if (option.selected) {
selectedMap[getId(String(index))] = true;
}
return selectedMap;
};
const checkboxGroupOptions = options.map(optionToCheckBoxGroupOption);
const idToSelectedMap = options.reduce(optionToSelectedMapReducer, {});
const onChange = (checkboxId: string) => {
const index = getIndexFromId(checkboxId);
const option = options[index];
if (option.selected) {
onRemove(option.value);
return;
}
onSelect(option.value);
};
return (
<>
<EuiCheckboxGroup
data-test-subj="checkbox-group"
className="documentsSearchExperience__facet"
legend={{ children: label }}
options={checkboxGroupOptions}
idToSelectedMap={idToSelectedMap}
onChange={onChange}
compressed={true}
/>
{showMore && (
<>
<EuiSpacer size="m" />
<EuiFlexGroup direction="row" justifyContent="center">
<EuiButtonEmpty
data-test-subj="more"
onClick={onMoreClick}
iconSide="right"
iconType="arrowDown"
size="xs"
>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.multiCheckboxFacetsView.showMore',
{
defaultMessage: 'Show more',
}
)}
</EuiButtonEmpty>
</EuiFlexGroup>
</>
)}
</>
);
};

View file

@ -7208,8 +7208,6 @@
"xpack.enterpriseSearch.appSearch.documents.search.indexingGuide": "インデックスガイドをお読みください",
"xpack.enterpriseSearch.appSearch.documents.search.noResults": "「{resultSearchTerm}」の結果がありません。",
"xpack.enterpriseSearch.appSearch.documents.search.placeholder": "ドキュメントのフィルター...",
"xpack.enterpriseSearch.appSearch.documents.search.recentlyUploadedAsc": "最近アップロードされたドキュメント(昇順)",
"xpack.enterpriseSearch.appSearch.documents.search.recentlyUploadedDesc": "最近アップロードされたドキュメント(降順)",
"xpack.enterpriseSearch.appSearch.documents.search.resultsPerPage.ariaLabel": "1 ページに表示する結果数",
"xpack.enterpriseSearch.appSearch.documents.search.resultsPerPage.show": "表示:",
"xpack.enterpriseSearch.appSearch.documents.search.sortBy": "並べ替え基準",

View file

@ -7227,8 +7227,6 @@
"xpack.enterpriseSearch.appSearch.documents.search.indexingGuide": "请阅读索引指南",
"xpack.enterpriseSearch.appSearch.documents.search.noResults": "还没有匹配“{resultSearchTerm}”的结果!",
"xpack.enterpriseSearch.appSearch.documents.search.placeholder": "筛选文档......",
"xpack.enterpriseSearch.appSearch.documents.search.recentlyUploadedAsc": "最近上传(升序)",
"xpack.enterpriseSearch.appSearch.documents.search.recentlyUploadedDesc": "最近上传(降序)",
"xpack.enterpriseSearch.appSearch.documents.search.resultsPerPage.ariaLabel": "每页要显示的结果数",
"xpack.enterpriseSearch.appSearch.documents.search.resultsPerPage.show": "显示:",
"xpack.enterpriseSearch.appSearch.documents.search.sortBy": "排序依据",