[App Search] Add configurable Filter and Sort modal to Documents View (#88066)
This commit is contained in:
parent
e2fc156bc5
commit
329a5a3f21
|
@ -4,15 +4,18 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* 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.mock('react', () => ({
|
||||||
...(jest.requireActual('react') as object),
|
...(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():
|
* Example usage within a component test using shallow():
|
||||||
*
|
*
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -20,4 +20,9 @@
|
||||||
.documentsSearchExperience__pagingInfo {
|
.documentsSearchExperience__pagingInfo {
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.customizationCallout {
|
||||||
|
background-color: $euiPageBackgroundColor;
|
||||||
|
padding: $euiSizeL;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,11 +7,26 @@ import '../../../../__mocks__/kea.mock';
|
||||||
import { setMockValues } from '../../../../__mocks__';
|
import { setMockValues } from '../../../../__mocks__';
|
||||||
import '../../../../__mocks__/enterprise_search_url.mock';
|
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';
|
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 } from '@elastic/react-search-ui';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
import { CustomizationCallout } from './customization_callout';
|
||||||
|
import { CustomizationModal } from './customization_modal';
|
||||||
|
|
||||||
import { SearchExperience } from './search_experience';
|
import { SearchExperience } from './search_experience';
|
||||||
|
|
||||||
describe('SearchExperience', () => {
|
describe('SearchExperience', () => {
|
||||||
|
@ -31,4 +46,40 @@ describe('SearchExperience', () => {
|
||||||
const wrapper = shallow(<SearchExperience />);
|
const wrapper = shallow(<SearchExperience />);
|
||||||
expect(wrapper.find(SearchProvider).length).toBe(1);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* or more contributor license agreements. Licensed under the Elastic License;
|
* or more contributor license agreements. Licensed under the Elastic License;
|
||||||
* you may not use this file except in compliance with 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 { i18n } from '@kbn/i18n';
|
||||||
import { useValues } from 'kea';
|
import { useValues } from 'kea';
|
||||||
|
@ -17,10 +17,13 @@ import './search_experience.scss';
|
||||||
|
|
||||||
import { EngineLogic } from '../../engine';
|
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 { SearchBoxView, SortingView } from './views';
|
import { SearchBoxView, SortingView } 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 { CustomizationModal } from './customization_modal';
|
||||||
|
|
||||||
const DEFAULT_SORT_OPTIONS = [
|
const DEFAULT_SORT_OPTIONS = [
|
||||||
{
|
{
|
||||||
|
@ -43,6 +46,18 @@ export const SearchExperience: React.FC = () => {
|
||||||
const { engine } = useValues(EngineLogic);
|
const { engine } = useValues(EngineLogic);
|
||||||
const endpointBase = externalUrl.enterpriseSearchUrl;
|
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
|
// 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 = [...DEFAULT_SORT_OPTIONS /* TODO ...sortFieldsOptions*/];
|
||||||
|
|
||||||
|
@ -85,12 +100,25 @@ export const SearchExperience: React.FC = () => {
|
||||||
sortOptions={sortingOptions}
|
sortOptions={sortingOptions}
|
||||||
view={SortingView}
|
view={SortingView}
|
||||||
/>
|
/>
|
||||||
|
<EuiSpacer />
|
||||||
|
<CustomizationCallout onClick={openCustomizationModal} />
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
<EuiFlexItem className="documentsSearchExperience__content">
|
<EuiFlexItem className="documentsSearchExperience__content">
|
||||||
<SearchExperienceContent />
|
<SearchExperienceContent />
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
</EuiFlexGroup>
|
</EuiFlexGroup>
|
||||||
</SearchProvider>
|
</SearchProvider>
|
||||||
|
{showCustomizationModal && (
|
||||||
|
<CustomizationModal
|
||||||
|
filterFields={fields.filterFields}
|
||||||
|
sortFields={fields.sortFields}
|
||||||
|
onClose={closeCustomizationModal}
|
||||||
|
onSave={({ filterFields, sortFields }) => {
|
||||||
|
setFields({ filterFields, sortFields });
|
||||||
|
closeCustomizationModal();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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';
|
|
@ -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"]}');
|
||||||
|
});
|
||||||
|
});
|
|
@ -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];
|
||||||
|
};
|
Loading…
Reference in a new issue