[App Search] Wired up configurable Sort and Facets in Documents View (#88764)
This commit is contained in:
parent
f4f6cb687c
commit
15f05b51ff
|
@ -15,24 +15,49 @@ describe('buildSearchUIConfig', () => {
|
||||||
foo: 'text' as SchemaTypes,
|
foo: 'text' as SchemaTypes,
|
||||||
bar: 'number' as SchemaTypes,
|
bar: 'number' as SchemaTypes,
|
||||||
};
|
};
|
||||||
|
const fields = {
|
||||||
|
filterFields: ['fieldA', 'fieldB'],
|
||||||
|
sortFields: [],
|
||||||
|
};
|
||||||
|
|
||||||
const config = buildSearchUIConfig(connector, schema);
|
const config = buildSearchUIConfig(connector, schema, fields);
|
||||||
expect(config.apiConnector).toEqual(connector);
|
expect(config).toEqual({
|
||||||
expect(config.searchQuery.result_fields).toEqual({
|
alwaysSearchOnInitialLoad: true,
|
||||||
bar: {
|
apiConnector: connector,
|
||||||
raw: {},
|
initialState: {
|
||||||
snippet: {
|
sortDirection: 'desc',
|
||||||
fallback: true,
|
sortField: 'id',
|
||||||
size: 300,
|
},
|
||||||
},
|
searchQuery: {
|
||||||
},
|
disjunctiveFacets: ['fieldA', 'fieldB'],
|
||||||
foo: {
|
facets: {
|
||||||
raw: {},
|
fieldA: {
|
||||||
snippet: {
|
size: 30,
|
||||||
fallback: true,
|
type: 'value',
|
||||||
size: 300,
|
},
|
||||||
|
fieldB: {
|
||||||
|
size: 30,
|
||||||
|
type: 'value',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
result_fields: {
|
||||||
|
bar: {
|
||||||
|
raw: {},
|
||||||
|
snippet: {
|
||||||
|
fallback: true,
|
||||||
|
size: 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
foo: {
|
||||||
|
raw: {},
|
||||||
|
snippet: {
|
||||||
|
fallback: true,
|
||||||
|
size: 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
trackUrlState: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,8 +5,17 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Schema } from '../../../../shared/types';
|
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 {
|
return {
|
||||||
alwaysSearchOnInitialLoad: true,
|
alwaysSearchOnInitialLoad: true,
|
||||||
apiConnector,
|
apiConnector,
|
||||||
|
@ -16,6 +25,8 @@ export const buildSearchUIConfig = (apiConnector: object, schema: Schema) => {
|
||||||
sortField: 'id',
|
sortField: 'id',
|
||||||
},
|
},
|
||||||
searchQuery: {
|
searchQuery: {
|
||||||
|
disjunctiveFacets: fields.filterFields,
|
||||||
|
facets,
|
||||||
result_fields: Object.keys(schema).reduce((acc: { [key: string]: object }, key: string) => {
|
result_fields: Object.keys(schema).reduce((acc: { [key: string]: object }, key: string) => {
|
||||||
acc[key] = {
|
acc[key] = {
|
||||||
snippet: {
|
snippet: {
|
||||||
|
|
|
@ -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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
};
|
|
@ -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 },
|
||||||
|
}
|
||||||
|
);
|
|
@ -25,4 +25,12 @@
|
||||||
background-color: $euiPageBackgroundColor;
|
background-color: $euiPageBackgroundColor;
|
||||||
padding: $euiSizeL;
|
padding: $euiSizeL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documentsSearchExperience__facet {
|
||||||
|
line-height: 0;
|
||||||
|
|
||||||
|
.euiCheckbox__label {
|
||||||
|
@include euiTextTruncate;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,25 +7,19 @@
|
||||||
import '../../../../__mocks__/enterprise_search_url.mock';
|
import '../../../../__mocks__/enterprise_search_url.mock';
|
||||||
import { setMockValues } from '../../../../__mocks__';
|
import { setMockValues } from '../../../../__mocks__';
|
||||||
|
|
||||||
const mockSetFields = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('../../../../shared/use_local_storage', () => ({
|
jest.mock('../../../../shared/use_local_storage', () => ({
|
||||||
useLocalStorage: jest.fn(() => [
|
useLocalStorage: jest.fn(),
|
||||||
{
|
|
||||||
filterFields: ['a', 'b', 'c'],
|
|
||||||
sortFields: ['d', 'c'],
|
|
||||||
},
|
|
||||||
mockSetFields,
|
|
||||||
]),
|
|
||||||
}));
|
}));
|
||||||
|
import { useLocalStorage } from '../../../../shared/use_local_storage';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
// @ts-expect-error types are not available for this package yet
|
// @ts-expect-error types are not available for this package yet
|
||||||
import { SearchProvider } from '@elastic/react-search-ui';
|
import { SearchProvider, Facet } from '@elastic/react-search-ui';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
|
||||||
import { CustomizationCallout } from './customization_callout';
|
import { CustomizationCallout } from './customization_callout';
|
||||||
import { CustomizationModal } from './customization_modal';
|
import { CustomizationModal } from './customization_modal';
|
||||||
|
import { Fields } from './types';
|
||||||
|
|
||||||
import { SearchExperience } from './search_experience';
|
import { SearchExperience } from './search_experience';
|
||||||
|
|
||||||
|
@ -36,8 +30,16 @@ describe('SearchExperience', () => {
|
||||||
apiKey: '1234',
|
apiKey: '1234',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const mockSetFields = jest.fn();
|
||||||
|
const setFieldsInLocalStorage = (fields: Fields) => {
|
||||||
|
(useLocalStorage as jest.Mock).mockImplementation(() => [fields, mockSetFields]);
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
setFieldsInLocalStorage({
|
||||||
|
filterFields: ['a', 'b', 'c'],
|
||||||
|
sortFields: ['d', 'c'],
|
||||||
|
});
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
setMockValues(values);
|
setMockValues(values);
|
||||||
});
|
});
|
||||||
|
@ -47,12 +49,60 @@ describe('SearchExperience', () => {
|
||||||
expect(wrapper.find(SearchProvider).length).toBe(1);
|
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', () => {
|
describe('customization modal', () => {
|
||||||
it('has a customization modal which can be opened and closed', () => {
|
it('has a customization modal which can be opened and closed', () => {
|
||||||
const wrapper = shallow(<SearchExperience />);
|
const wrapper = shallow(<SearchExperience />);
|
||||||
expect(wrapper.find(CustomizationModal).exists()).toBe(false);
|
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);
|
expect(wrapper.find(CustomizationModal).exists()).toBe(true);
|
||||||
|
|
||||||
wrapper.find(CustomizationModal).prop('onClose')();
|
wrapper.find(CustomizationModal).prop('onClose')();
|
||||||
|
@ -61,14 +111,14 @@ describe('SearchExperience', () => {
|
||||||
|
|
||||||
it('passes values from localStorage to the customization modal', () => {
|
it('passes values from localStorage to the customization modal', () => {
|
||||||
const wrapper = shallow(<SearchExperience />);
|
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('filterFields')).toEqual(['a', 'b', 'c']);
|
||||||
expect(wrapper.find(CustomizationModal).prop('sortFields')).toEqual(['d', 'c']);
|
expect(wrapper.find(CustomizationModal).prop('sortFields')).toEqual(['d', 'c']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates selected fields in localStorage and closes modal on save', () => {
|
it('updates selected fields in localStorage and closes modal on save', () => {
|
||||||
const wrapper = shallow(<SearchExperience />);
|
const wrapper = shallow(<SearchExperience />);
|
||||||
wrapper.find(CustomizationCallout).simulate('click');
|
wrapper.find('[data-test-subj="customize"]').simulate('click');
|
||||||
wrapper.find(CustomizationModal).prop('onSave')({
|
wrapper.find(CustomizationModal).prop('onSave')({
|
||||||
filterFields: ['new', 'filters'],
|
filterFields: ['new', 'filters'],
|
||||||
sortFields: ['new', 'sorts'],
|
sortFields: ['new', 'sorts'],
|
||||||
|
|
|
@ -7,36 +7,41 @@ import React, { useState } from 'react';
|
||||||
|
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import { useValues } from 'kea';
|
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;
|
// @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
|
// @ts-expect-error types are not available for this package yet
|
||||||
import AppSearchAPIConnector from '@elastic/search-ui-app-search-connector';
|
import AppSearchAPIConnector from '@elastic/search-ui-app-search-connector';
|
||||||
|
|
||||||
import './search_experience.scss';
|
import './search_experience.scss';
|
||||||
|
|
||||||
import { EngineLogic } from '../../engine';
|
|
||||||
import { externalUrl } from '../../../../shared/enterprise_search_url';
|
import { externalUrl } from '../../../../shared/enterprise_search_url';
|
||||||
import { useLocalStorage } from '../../../../shared/use_local_storage';
|
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 { SearchExperienceContent } from './search_experience_content';
|
||||||
import { buildSearchUIConfig } from './build_search_ui_config';
|
import { buildSearchUIConfig } from './build_search_ui_config';
|
||||||
import { CustomizationCallout } from './customization_callout';
|
import { CustomizationCallout } from './customization_callout';
|
||||||
import { CustomizationModal } from './customization_modal';
|
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',
|
||||||
defaultMessage: 'Recently Uploaded (desc)',
|
}
|
||||||
}),
|
);
|
||||||
|
const DEFAULT_SORT_OPTIONS: SortOption[] = [
|
||||||
|
{
|
||||||
|
name: DESCENDING(RECENTLY_UPLOADED),
|
||||||
value: 'id',
|
value: 'id',
|
||||||
direction: 'desc',
|
direction: 'desc',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n.translate('xpack.enterpriseSearch.appSearch.documents.search.recentlyUploadedAsc', {
|
name: ASCENDING(RECENTLY_UPLOADED),
|
||||||
defaultMessage: 'Recently Uploaded (asc)',
|
|
||||||
}),
|
|
||||||
value: 'id',
|
value: 'id',
|
||||||
direction: 'asc',
|
direction: 'asc',
|
||||||
},
|
},
|
||||||
|
@ -50,16 +55,15 @@ export const SearchExperience: React.FC = () => {
|
||||||
const openCustomizationModal = () => setShowCustomizationModal(true);
|
const openCustomizationModal = () => setShowCustomizationModal(true);
|
||||||
const closeCustomizationModal = () => setShowCustomizationModal(false);
|
const closeCustomizationModal = () => setShowCustomizationModal(false);
|
||||||
|
|
||||||
const [fields, setFields] = useLocalStorage(
|
const [fields, setFields] = useLocalStorage<Fields>(
|
||||||
`documents-search-experience-customization--${engine.name}`,
|
`documents-search-experience-customization--${engine.name}`,
|
||||||
{
|
{
|
||||||
filterFields: [] as string[],
|
filterFields: [],
|
||||||
sortFields: [] as string[],
|
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 = buildSortOptions(fields, DEFAULT_SORT_OPTIONS);
|
||||||
const sortingOptions = [...DEFAULT_SORT_OPTIONS /* TODO ...sortFieldsOptions*/];
|
|
||||||
|
|
||||||
const connector = new AppSearchAPIConnector({
|
const connector = new AppSearchAPIConnector({
|
||||||
cacheResponses: false,
|
cacheResponses: false,
|
||||||
|
@ -68,7 +72,7 @@ export const SearchExperience: React.FC = () => {
|
||||||
searchKey: engine.apiKey,
|
searchKey: engine.apiKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchProviderConfig = buildSearchUIConfig(connector, engine.schema || {});
|
const searchProviderConfig = buildSearchUIConfig(connector, engine.schema || {}, fields);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="documentsSearchExperience">
|
<div className="documentsSearchExperience">
|
||||||
|
@ -101,7 +105,36 @@ export const SearchExperience: React.FC = () => {
|
||||||
view={SortingView}
|
view={SortingView}
|
||||||
/>
|
/>
|
||||||
<EuiSpacer />
|
<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>
|
||||||
<EuiFlexItem className="documentsSearchExperience__content">
|
<EuiFlexItem className="documentsSearchExperience__content">
|
||||||
<SearchExperienceContent />
|
<SearchExperienceContent />
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -9,3 +9,4 @@ export { SortingView } from './sorting_view';
|
||||||
export { ResultView } from './result_view';
|
export { ResultView } from './result_view';
|
||||||
export { ResultsPerPageView } from './results_per_page_view';
|
export { ResultsPerPageView } from './results_per_page_view';
|
||||||
export { PagingView } from './paging_view';
|
export { PagingView } from './paging_view';
|
||||||
|
export { MultiCheckboxFacetsView } from './multi_checkbox_facets_view';
|
||||||
|
|
|
@ -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>' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -7208,8 +7208,6 @@
|
||||||
"xpack.enterpriseSearch.appSearch.documents.search.indexingGuide": "インデックスガイドをお読みください",
|
"xpack.enterpriseSearch.appSearch.documents.search.indexingGuide": "インデックスガイドをお読みください",
|
||||||
"xpack.enterpriseSearch.appSearch.documents.search.noResults": "「{resultSearchTerm}」の結果がありません。",
|
"xpack.enterpriseSearch.appSearch.documents.search.noResults": "「{resultSearchTerm}」の結果がありません。",
|
||||||
"xpack.enterpriseSearch.appSearch.documents.search.placeholder": "ドキュメントのフィルター...",
|
"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.ariaLabel": "1 ページに表示する結果数",
|
||||||
"xpack.enterpriseSearch.appSearch.documents.search.resultsPerPage.show": "表示:",
|
"xpack.enterpriseSearch.appSearch.documents.search.resultsPerPage.show": "表示:",
|
||||||
"xpack.enterpriseSearch.appSearch.documents.search.sortBy": "並べ替え基準",
|
"xpack.enterpriseSearch.appSearch.documents.search.sortBy": "並べ替え基準",
|
||||||
|
|
|
@ -7227,8 +7227,6 @@
|
||||||
"xpack.enterpriseSearch.appSearch.documents.search.indexingGuide": "请阅读索引指南",
|
"xpack.enterpriseSearch.appSearch.documents.search.indexingGuide": "请阅读索引指南",
|
||||||
"xpack.enterpriseSearch.appSearch.documents.search.noResults": "还没有匹配“{resultSearchTerm}”的结果!",
|
"xpack.enterpriseSearch.appSearch.documents.search.noResults": "还没有匹配“{resultSearchTerm}”的结果!",
|
||||||
"xpack.enterpriseSearch.appSearch.documents.search.placeholder": "筛选文档......",
|
"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.ariaLabel": "每页要显示的结果数",
|
||||||
"xpack.enterpriseSearch.appSearch.documents.search.resultsPerPage.show": "显示:",
|
"xpack.enterpriseSearch.appSearch.documents.search.resultsPerPage.show": "显示:",
|
||||||
"xpack.enterpriseSearch.appSearch.documents.search.sortBy": "排序依据",
|
"xpack.enterpriseSearch.appSearch.documents.search.sortBy": "排序依据",
|
||||||
|
|
Loading…
Reference in a new issue