diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_useeffect.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_useeffect.mock.ts index 1e3a45a83853..ce4ec3950573 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_useeffect.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_useeffect.mock.ts @@ -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(): * diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_callout.test.tsx new file mode 100644 index 000000000000..7864a6411ffa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_callout.test.tsx @@ -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(); + }); + + 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(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_callout.tsx new file mode 100644 index 000000000000..a9bec6ced828 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_callout.tsx @@ -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 = ({ onClick }) => { + return ( + + + + + + {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.', + } + )} + + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationCallout.button', + { + defaultMessage: 'Customize', + } + )} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.test.tsx new file mode 100644 index 000000000000..94b2ab7cf3f0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.test.tsx @@ -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(); + expect(wrapper.isEmptyRender()).toBe(false); + }); + + it('when save is clicked, it calls onSave prop with selected filter and sort fields', () => { + const wrapper = shallow(); + 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(); + + 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(); + wrapper.find(EuiButtonEmpty).simulate('click'); + expect(props.onClose).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx new file mode 100644 index 000000000000..2b05ed7e78f6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/customization_modal.tsx @@ -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 = ({ + 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 ( + + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.title', + { + defaultMessage: 'Customize document search', + } + )} + + + + + + + + + + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.cancel', + { + defaultMessage: 'Cancel', + } + )} + + { + onSave({ + filterFields: selectedFilterFields.map(comboBoxOptionToFieldName), + sortFields: selectedSortFields.map(comboBoxOptionToFieldName), + }); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.save', + { + defaultMessage: 'Save', + } + )} + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss index 868a561a2787..ba9931dc90fd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss @@ -20,4 +20,9 @@ .documentsSearchExperience__pagingInfo { flex-grow: 0; } + + .customizationCallout { + background-color: $euiPageBackgroundColor; + padding: $euiSizeL; + } } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx index 750d00311255..250cd00943d7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.test.tsx @@ -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(); expect(wrapper.find(SearchProvider).length).toBe(1); }); + + describe('customization modal', () => { + it('has a customization modal which can be opened and closed', () => { + const wrapper = shallow(); + 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(); + 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(); + 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); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx index 1501efc589fc..e80ab2e18b2d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx @@ -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} /> + + + {showCustomizationModal && ( + { + setFields({ filterFields, sortFields }); + closeCustomizationModal(); + }} + /> + )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/use_local_storage/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/use_local_storage/index.ts new file mode 100644 index 000000000000..8c75ca9ae43c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/use_local_storage/index.ts @@ -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'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/use_local_storage/use_local_storage.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/use_local_storage/use_local_storage.test.tsx new file mode 100644 index 000000000000..0b0edcdf86f6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/use_local_storage/use_local_storage.test.tsx @@ -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 ( +
+
+ ); + }; + + 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(); + 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(); + 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(); + 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(); + wrapper.find('#change').simulate('click'); + expect(wrapper.text()).toBe('big, new, values'); + expect(global.localStorage.getItem(KEY)).toBe('{"options":["big","new","values"]}'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/use_local_storage/use_local_storage.ts b/x-pack/plugins/enterprise_search/public/applications/shared/use_local_storage/use_local_storage.ts new file mode 100644 index 000000000000..11f5fdb5aa1d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/use_local_storage/use_local_storage.ts @@ -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 = (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(toStore); + + const saveItem = (value: Value) => { + saveToStorage(value); + setItem(value); + }; + + return [item, saveItem]; +};