[App Search] Add configurable Filter and Sort modal to Documents View (#88066)

This commit is contained in:
Jason Stoltzfus 2021-01-15 11:45:24 -05:00 committed by GitHub
parent e2fc156bc5
commit 329a5a3f21
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 534 additions and 6 deletions

View file

@ -4,15 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
// Helper for calling the returned useEffect unmount handler
let mockUnmountHandler: () => void;
export const unmountHandler = () => mockUnmountHandler();
jest.mock('react', () => ({
...(jest.requireActual('react') as object),
useEffect: jest.fn((fn) => fn()), // Calls on mount/every update - use mount for more complex behavior
useEffect: jest.fn((fn) => {
mockUnmountHandler = fn();
return mockUnmountHandler;
}), // Calls on mount/every update - use mount for more complex behavior
}));
// Helper for calling the returned useEffect unmount handler
import { useEffect } from 'react';
export const unmountHandler = () => (useEffect as jest.Mock).mock.calls[0][0]()();
/**
* Example usage within a component test using shallow():
*

View file

@ -0,0 +1,30 @@
/*
* 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, ShallowWrapper } from 'enzyme';
import { EuiButton } from '@elastic/eui';
import { CustomizationCallout } from './customization_callout';
describe('CustomizationCallout', () => {
let wrapper: ShallowWrapper;
const onClick = jest.fn();
beforeAll(() => {
wrapper = shallow(<CustomizationCallout onClick={onClick} />);
});
it('renders', () => {
expect(wrapper.isEmptyRender()).toBe(false);
});
it('calls onClick param when the Customize button is clicked', () => {
wrapper.find(EuiButton).simulate('click');
expect(onClick).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { EuiButton, EuiFlexGroup, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui';
interface Props {
onClick(): void;
}
export const CustomizationCallout: React.FC<Props> = ({ onClick }) => {
return (
<EuiFlexGroup
direction="column"
className="customizationCallout"
alignItems="center"
gutterSize="none"
>
<EuiIcon type="iInCircle" color="primary" size="xxl" />
<EuiSpacer />
<EuiText size="s" textAlign="center">
<strong>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.customizationCallout.message',
{
defaultMessage:
'Did you know that you can customize your document search experience? Click "Customize" below to get started.',
}
)}
</strong>
</EuiText>
<EuiSpacer />
<EuiButton fill color="primary" iconType="gear" onClick={onClick}>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.customizationCallout.button',
{
defaultMessage: 'Customize',
}
)}
</EuiButton>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,75 @@
/*
* 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 { setMockValues, setMockActions } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { CustomizationModal } from './customization_modal';
describe('CustomizationModal', () => {
const props = {
filterFields: ['field1', 'field2'],
sortFields: ['sortField1', 'sortField2'],
onClose: jest.fn(),
onSave: jest.fn(),
};
const values = {
engine: {
name: 'some-engine',
apiKey: '1234',
},
};
const actions = {
setEngine: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
it('renders', () => {
const wrapper = shallow(<CustomizationModal {...props} />);
expect(wrapper.isEmptyRender()).toBe(false);
});
it('when save is clicked, it calls onSave prop with selected filter and sort fields', () => {
const wrapper = shallow(<CustomizationModal {...props} />);
wrapper.find(EuiButton).simulate('click');
expect(props.onSave).toHaveBeenCalledWith({
filterFields: ['field1', 'field2'],
sortFields: ['sortField1', 'sortField2'],
});
});
it('when save is clicked, it calls onSave prop when with updated selections', () => {
const wrapper = shallow(<CustomizationModal {...props} />);
const sortFieldsDropdown = wrapper.find('[data-test-subj="sortFieldsDropdown"]');
sortFieldsDropdown.simulate('change', [{ label: 'newSort1' }]);
const filterFieldsDropdown = wrapper.find('[data-test-subj="filterFieldsDropdown"]');
filterFieldsDropdown.simulate('change', [{ label: 'newField1' }]);
wrapper.find(EuiButton).simulate('click');
expect(props.onSave).toHaveBeenCalledWith({
filterFields: ['newField1'],
sortFields: ['newSort1'],
});
});
it('calls onClose when cancel is clicked', () => {
const wrapper = shallow(<CustomizationModal {...props} />);
wrapper.find(EuiButtonEmpty).simulate('click');
expect(props.onClose).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,156 @@
/*
* 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, { useState, useMemo } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiComboBox,
EuiForm,
EuiFormRow,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiOverlayMask,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useValues } from 'kea';
import { EngineLogic } from '../../engine';
interface Props {
filterFields: string[];
sortFields: string[];
onClose(): void;
onSave({ sortFields, filterFields }: { sortFields: string[]; filterFields: string[] }): void;
}
const fieldNameToComboBoxOption = (fieldName: string) => ({ label: fieldName });
const comboBoxOptionToFieldName = ({ label }: { label: string }) => label;
export const CustomizationModal: React.FC<Props> = ({
filterFields,
onClose,
onSave,
sortFields,
}) => {
const { engine } = useValues(EngineLogic);
const [selectedFilterFields, setSelectedFilterFields] = useState(
filterFields.map(fieldNameToComboBoxOption)
);
const [selectedSortFields, setSelectedSortFields] = useState(
sortFields.map(fieldNameToComboBoxOption)
);
const engineSchema = engine.schema || {};
const selectableFilterFields = useMemo(
() => Object.keys(engineSchema).map(fieldNameToComboBoxOption),
[engineSchema]
);
const selectableSortFields = useMemo(
() => Object.keys(engineSchema).map(fieldNameToComboBoxOption),
[engineSchema]
);
return (
<EuiOverlayMask>
<EuiModal onClose={onClose}>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.title',
{
defaultMessage: 'Customize document search',
}
)}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiForm>
<EuiFormRow
label={i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.filterFieldsLabel',
{
defaultMessage: 'Filter fields',
}
)}
fullWidth={true}
helpText={i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.filterFields',
{
defaultMessage:
'Faceted values rendered as filters and available as query refinement',
}
)}
>
<EuiComboBox
data-test-subj="filterFieldsDropdown"
fullWidth={true}
options={selectableFilterFields}
selectedOptions={selectedFilterFields}
onChange={setSelectedFilterFields}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.sortFieldsLabel',
{
defaultMessage: 'Sort fields',
}
)}
fullWidth={true}
helpText={i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.sortFields',
{
defaultMessage:
'Used to display result sorting options, ascending and descending',
}
)}
>
<EuiComboBox
data-test-subj="sortFieldsDropdown"
fullWidth={true}
options={selectableSortFields}
selectedOptions={selectedSortFields}
onChange={setSelectedSortFields}
/>
</EuiFormRow>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onClose}>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.cancel',
{
defaultMessage: 'Cancel',
}
)}
</EuiButtonEmpty>
<EuiButton
fill
onClick={() => {
onSave({
filterFields: selectedFilterFields.map(comboBoxOptionToFieldName),
sortFields: selectedSortFields.map(comboBoxOptionToFieldName),
});
}}
>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.save',
{
defaultMessage: 'Save',
}
)}
</EuiButton>
</EuiModalFooter>
</EuiModal>
</EuiOverlayMask>
);
};

View file

@ -20,4 +20,9 @@
.documentsSearchExperience__pagingInfo {
flex-grow: 0;
}
.customizationCallout {
background-color: $euiPageBackgroundColor;
padding: $euiSizeL;
}
}

View file

@ -7,11 +7,26 @@ import '../../../../__mocks__/kea.mock';
import { setMockValues } from '../../../../__mocks__';
import '../../../../__mocks__/enterprise_search_url.mock';
const mockSetFields = jest.fn();
jest.mock('../../../../shared/use_local_storage', () => ({
useLocalStorage: jest.fn(() => [
{
filterFields: ['a', 'b', 'c'],
sortFields: ['d', 'c'],
},
mockSetFields,
]),
}));
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 { CustomizationCallout } from './customization_callout';
import { CustomizationModal } from './customization_modal';
import { SearchExperience } from './search_experience';
describe('SearchExperience', () => {
@ -31,4 +46,40 @@ describe('SearchExperience', () => {
const wrapper = shallow(<SearchExperience />);
expect(wrapper.find(SearchProvider).length).toBe(1);
});
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');
expect(wrapper.find(CustomizationModal).exists()).toBe(true);
wrapper.find(CustomizationModal).prop('onClose')();
expect(wrapper.find(CustomizationModal).exists()).toBe(false);
});
it('passes values from localStorage to the customization modal', () => {
const wrapper = shallow(<SearchExperience />);
wrapper.find(CustomizationCallout).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(CustomizationModal).prop('onSave')({
filterFields: ['new', 'filters'],
sortFields: ['new', 'sorts'],
});
expect(mockSetFields).toHaveBeenCalledWith({
filterFields: ['new', 'filters'],
sortFields: ['new', 'sorts'],
});
expect(wrapper.find(CustomizationModal).exists()).toBe(false);
});
});
});

View file

@ -3,7 +3,7 @@
* 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 React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { useValues } from 'kea';
@ -17,10 +17,13 @@ import './search_experience.scss';
import { EngineLogic } from '../../engine';
import { externalUrl } from '../../../../shared/enterprise_search_url';
import { useLocalStorage } from '../../../../shared/use_local_storage';
import { SearchBoxView, SortingView } 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';
const DEFAULT_SORT_OPTIONS = [
{
@ -43,6 +46,18 @@ export const SearchExperience: React.FC = () => {
const { engine } = useValues(EngineLogic);
const endpointBase = externalUrl.enterpriseSearchUrl;
const [showCustomizationModal, setShowCustomizationModal] = useState(false);
const openCustomizationModal = () => setShowCustomizationModal(true);
const closeCustomizationModal = () => setShowCustomizationModal(false);
const [fields, setFields] = useLocalStorage(
`documents-search-experience-customization--${engine.name}`,
{
filterFields: [] as string[],
sortFields: [] as string[],
}
);
// 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*/];
@ -85,12 +100,25 @@ export const SearchExperience: React.FC = () => {
sortOptions={sortingOptions}
view={SortingView}
/>
<EuiSpacer />
<CustomizationCallout onClick={openCustomizationModal} />
</EuiFlexItem>
<EuiFlexItem className="documentsSearchExperience__content">
<SearchExperienceContent />
</EuiFlexItem>
</EuiFlexGroup>
</SearchProvider>
{showCustomizationModal && (
<CustomizationModal
filterFields={fields.filterFields}
sortFields={fields.sortFields}
onClose={closeCustomizationModal}
onSave={({ filterFields, sortFields }) => {
setFields({ filterFields, sortFields });
closeCustomizationModal();
}}
/>
)}
</div>
);
};

View file

@ -0,0 +1,7 @@
/*
* 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 { useLocalStorage } from './use_local_storage';

View file

@ -0,0 +1,69 @@
/*
* 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 { useLocalStorage } from './use_local_storage';
describe('useLocalStorage', () => {
const KEY = 'fields';
const TestComponent = () => {
const [fields, setFields] = useLocalStorage(KEY, {
options: ['foo', 'bar', 'baz'],
});
return (
<div>
<button
id="change"
onClick={() => {
setFields({
options: ['big', 'new', 'values'],
});
}}
/>
{fields?.options?.join(', ')}
</div>
);
};
beforeEach(() => {
global.localStorage.clear();
jest.clearAllMocks();
});
it('will read state from localStorage on init if values already exist', () => {
global.localStorage.setItem(
KEY,
JSON.stringify({
options: ['some', 'old', 'values'],
})
);
const wrapper = shallow(<TestComponent />);
expect(wrapper.text()).toBe('some, old, values');
});
it('will ignore non-JSON values in localStorage', () => {
global.localStorage.setItem(KEY, 'blah blah blah');
const wrapper = shallow(<TestComponent />);
expect(wrapper.text()).toBe('foo, bar, baz');
expect(global.localStorage.getItem(KEY)).toBe('{"options":["foo","bar","baz"]}');
});
it('if will use provided default values if state does not already exist in localStorage', () => {
const wrapper = shallow(<TestComponent />);
expect(wrapper.text()).toBe('foo, bar, baz');
expect(global.localStorage.getItem(KEY)).toBe('{"options":["foo","bar","baz"]}');
});
it('state can be updated with new values', () => {
const wrapper = shallow(<TestComponent />);
wrapper.find('#change').simulate('click');
expect(wrapper.text()).toBe('big, new, values');
expect(global.localStorage.getItem(KEY)).toBe('{"options":["big","new","values"]}');
});
});

View file

@ -0,0 +1,56 @@
/*
* 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 { useState } from 'react';
/**
* A hook that works like `useState`, but persisted to localStorage.
*
* example:
*
* const [foo, setFoo] = useLocalStorage("foo", "bar");
*
* console.log(foo) // "bar"
* setFoo("baz")
* console.log(foo) // "baz"
*
* // Navigate away from page and return
*
* const [foo, setFoo] = useLocalStorage("foo", "bar");
* console.log(foo) // "baz"
*/
export const useLocalStorage = <Value>(key: string, defaultValue: Value): [Value, Function] => {
const saveToStorage = (value: Value) => window.localStorage.setItem(key, JSON.stringify(value));
const removeFromStorage = () => window.localStorage.removeItem(key);
const getFromStorage = (): Value | undefined => {
const storedItem = window.localStorage.getItem(key);
if (!storedItem) return;
let parsedItem;
try {
return JSON.parse(storedItem) as Value;
} catch (e) {
removeFromStorage();
}
return parsedItem;
};
const storedItem = getFromStorage();
if (!storedItem) {
saveToStorage(defaultValue);
}
const toStore = storedItem || defaultValue;
const [item, setItem] = useState<Value>(toStore);
const saveItem = (value: Value) => {
saveToStorage(value);
setItem(value);
};
return [item, saveItem];
};