diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts index d16391089120a..23d638d5f25f3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_logic.mock.ts @@ -12,6 +12,7 @@ import { generateEncodedPath } from '../utils/encode_path_params'; export const mockEngineValues = { engineName: 'some-engine', engine: {} as EngineDetails, + searchKey: 'search-abc123', }; export const mockEngineActions = { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts index 5435450f1bfdb..836265a037e16 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts @@ -9,6 +9,8 @@ import { LogicMounter, mockHttpValues } from '../../../__mocks__'; import { nextTick } from '@kbn/test/jest'; +import { ApiTokenTypes } from '../credentials/constants'; + import { EngineTypes } from './types'; import { EngineLogic } from './'; @@ -47,6 +49,7 @@ describe('EngineLogic', () => { hasSchemaConflicts: false, hasUnconfirmedSchemaFields: false, engineNotFound: false, + searchKey: '', }; beforeEach(() => { @@ -263,5 +266,57 @@ describe('EngineLogic', () => { }); }); }); + + describe('searchKey', () => { + it('should select the first available search key for this engine', () => { + const engine = { + ...mockEngineData, + apiTokens: [ + { + key: 'private-123xyz', + name: 'privateKey', + type: ApiTokenTypes.Private, + }, + { + key: 'search-123xyz', + name: 'searchKey', + type: ApiTokenTypes.Search, + }, + { + key: 'search-8910abc', + name: 'searchKey2', + type: ApiTokenTypes.Search, + }, + ], + }; + mount({ engine }); + + expect(EngineLogic.values).toEqual({ + ...DEFAULT_VALUES, + engine, + searchKey: 'search-123xyz', + }); + }); + + it('should return an empty string if none are available', () => { + const engine = { + ...mockEngineData, + apiTokens: [ + { + key: 'private-123xyz', + name: 'privateKey', + type: ApiTokenTypes.Private, + }, + ], + }; + mount({ engine }); + + expect(EngineLogic.values).toEqual({ + ...DEFAULT_VALUES, + engine, + searchKey: '', + }); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts index 8fbf45bc85e52..5cbe89b364859 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.ts @@ -8,6 +8,8 @@ import { kea, MakeLogicType } from 'kea'; import { HttpLogic } from '../../../shared/http'; +import { ApiTokenTypes } from '../credentials/constants'; +import { ApiToken } from '../credentials/types'; import { EngineDetails, EngineTypes } from './types'; @@ -21,6 +23,7 @@ interface EngineValues { hasSchemaConflicts: boolean; hasUnconfirmedSchemaFields: boolean; engineNotFound: boolean; + searchKey: string; } interface EngineActions { @@ -87,6 +90,14 @@ export const EngineLogic = kea>({ () => [selectors.engine], (engine) => engine?.unconfirmedFields?.length > 0, ], + searchKey: [ + () => [selectors.engine], + (engine: Partial) => { + const isSearchKey = (token: ApiToken) => token.type === ApiTokenTypes.Search; + const searchKey = (engine.apiTokens || []).find(isSearchKey); + return searchKey?.key || ''; + }, + ], }), listeners: ({ actions, values }) => ({ initializeEngine: async () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_form.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_form.test.tsx index b015c2dec6c0a..82b925f57f2df 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_form.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_form.test.tsx @@ -13,7 +13,9 @@ import { setMockValues, setMockActions } from '../../../../__mocks__'; import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiForm } from '@elastic/eui'; import { ActiveField } from '../types'; import { generatePreviewUrl } from '../utils'; @@ -29,6 +31,7 @@ describe('SearchUIForm', () => { urlField: 'url', facetFields: ['category'], sortFields: ['size'], + dataLoading: false, }; const actions = { onActiveFieldChange: jest.fn(), @@ -43,10 +46,6 @@ describe('SearchUIForm', () => { setMockActions(actions); }); - beforeEach(() => { - jest.clearAllMocks(); - }); - it('renders', () => { const wrapper = shallow(); expect(wrapper.find('[data-test-subj="selectTitle"]').exists()).toBe(true); @@ -56,6 +55,7 @@ describe('SearchUIForm', () => { }); describe('title field', () => { + beforeEach(() => jest.clearAllMocks()); const subject = () => shallow().find('[data-test-subj="selectTitle"]'); it('renders with its value set from state', () => { @@ -84,6 +84,7 @@ describe('SearchUIForm', () => { }); describe('url field', () => { + beforeEach(() => jest.clearAllMocks()); const subject = () => shallow().find('[data-test-subj="selectUrl"]'); it('renders with its value set from state', () => { @@ -112,6 +113,7 @@ describe('SearchUIForm', () => { }); describe('filters field', () => { + beforeEach(() => jest.clearAllMocks()); const subject = () => shallow().find('[data-test-subj="selectFilters"]'); it('renders with its value set from state', () => { @@ -145,6 +147,7 @@ describe('SearchUIForm', () => { }); describe('sorts field', () => { + beforeEach(() => jest.clearAllMocks()); const subject = () => shallow().find('[data-test-subj="selectSort"]'); it('renders with its value set from state', () => { @@ -177,26 +180,61 @@ describe('SearchUIForm', () => { }); }); - it('includes a link to generate the preview', () => { - (generatePreviewUrl as jest.Mock).mockReturnValue('http://www.example.com?foo=bar'); + describe('generate preview button', () => { + let wrapper: ShallowWrapper; - setMockValues({ - ...values, - urlField: 'foo', - titleField: 'bar', - facetFields: ['baz'], - sortFields: ['qux'], + beforeAll(() => { + jest.clearAllMocks(); + (generatePreviewUrl as jest.Mock).mockReturnValue('http://www.example.com?foo=bar'); + setMockValues({ + ...values, + urlField: 'foo', + titleField: 'bar', + facetFields: ['baz'], + sortFields: ['qux'], + searchKey: 'search-123abc', + }); + wrapper = shallow(); + }); + + it('should be a submit button', () => { + expect(wrapper.find('[data-test-subj="generateSearchUiPreview"]').prop('type')).toBe( + 'submit' + ); + }); + + it('should be wrapped in a form configured to POST to the preview screen in a new tab', () => { + const form = wrapper.find(EuiForm); + expect(generatePreviewUrl).toHaveBeenCalledWith({ + urlField: 'foo', + titleField: 'bar', + facets: ['baz'], + sortFields: ['qux'], + }); + expect(form.prop('action')).toBe('http://www.example.com?foo=bar'); + expect(form.prop('target')).toBe('_blank'); + expect(form.prop('method')).toBe('POST'); + expect(form.prop('component')).toBe('form'); }); - const subject = () => - shallow().find('[data-test-subj="generateSearchUiPreview"]'); + it('should include a searchKey in that form POST', () => { + const form = wrapper.find(EuiForm); + const hiddenInput = form.find('input[type="hidden"]'); + expect(hiddenInput.prop('id')).toBe('searchKey'); + expect(hiddenInput.prop('value')).toBe('search-123abc'); + }); + }); - expect(subject().prop('href')).toBe('http://www.example.com?foo=bar'); - expect(generatePreviewUrl).toHaveBeenCalledWith({ - urlField: 'foo', - titleField: 'bar', - facets: ['baz'], - sortFields: ['qux'], + it('should disable everything while data is loading', () => { + setMockValues({ + ...values, + dataLoading: true, }); + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="selectTitle"]').prop('disabled')).toBe(true); + expect(wrapper.find('[data-test-subj="selectFilters"]').prop('isDisabled')).toBe(true); + expect(wrapper.find('[data-test-subj="selectSort"]').prop('isDisabled')).toBe(true); + expect(wrapper.find('[data-test-subj="selectUrl"]').prop('disabled')).toBe(true); + expect(wrapper.find('[data-test-subj="generateSearchUiPreview"]').prop('disabled')).toBe(true); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_form.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_form.tsx index 15bd699be721c..b795a46268237 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_form.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/components/search_ui_form.tsx @@ -11,6 +11,7 @@ import { useValues, useActions } from 'kea'; import { EuiForm, EuiFormRow, EuiSelect, EuiComboBox, EuiButton } from '@elastic/eui'; +import { EngineLogic } from '../../engine'; import { TITLE_FIELD_LABEL, TITLE_FIELD_HELP_TEXT, @@ -27,7 +28,9 @@ import { ActiveField } from '../types'; import { generatePreviewUrl } from '../utils'; export const SearchUIForm: React.FC = () => { + const { searchKey } = useValues(EngineLogic); const { + dataLoading, validFields, validSortFields, validFacetFields, @@ -70,9 +73,11 @@ export const SearchUIForm: React.FC = () => { const selectedFacetOptions = formatMultiOptions(facetFields); return ( - + + onTitleFieldChange(e.target.value)} @@ -85,6 +90,7 @@ export const SearchUIForm: React.FC = () => { onFacetFieldsChange(newValues.map((field) => field.value!))} @@ -96,6 +102,7 @@ export const SearchUIForm: React.FC = () => { onSortFieldsChange(newValues.map((field) => field.value!))} @@ -108,6 +115,7 @@ export const SearchUIForm: React.FC = () => { onUrlFieldChange(e.target.value)} @@ -119,8 +127,8 @@ export const SearchUIForm: React.FC = () => { /> + i18n.translate('xpack.enterpriseSearch.appSearch.engine.searchUI.noSearchKeyErrorMessage', { + defaultMessage: + "It looks like you don't have any Public Search Keys with access to the '{engineName}' engine. Please visit the {credentialsTitle} page to set one up.", + values: { engineName, credentialsTitle: CREDENTIALS_TITLE }, + }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.test.ts index 2191c633131bb..2e29ac0d398a6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.test.ts @@ -18,7 +18,7 @@ import { SearchUILogic } from './'; describe('SearchUILogic', () => { const { mount } = new LogicMounter(SearchUILogic); const { http } = mockHttpValues; - const { flashAPIErrors } = mockFlashMessageHelpers; + const { flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers; const DEFAULT_VALUES = { dataLoading: true, @@ -35,6 +35,7 @@ describe('SearchUILogic', () => { beforeEach(() => { jest.clearAllMocks(); mockEngineValues.engineName = 'engine1'; + mockEngineValues.searchKey = 'search-abc123'; }); it('has expected default values', () => { @@ -155,6 +156,17 @@ describe('SearchUILogic', () => { }); }); + it('will short circuit the call if there is no searchKey available for this engine', async () => { + mockEngineValues.searchKey = ''; + mount(); + + SearchUILogic.actions.loadFieldData(); + + expect(setErrorMessage).toHaveBeenCalledWith( + "It looks like you don't have any Public Search Keys with access to the 'engine1' engine. Please visit the Credentials page to set one up." + ); + }); + it('handles errors', async () => { http.get.mockReturnValueOnce(Promise.reject('error')); mount(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.ts index c9e2e5623d9fd..096365f57ea36 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/search_ui_logic.ts @@ -7,10 +7,12 @@ import { kea, MakeLogicType } from 'kea'; -import { flashAPIErrors } from '../../../shared/flash_messages'; +import { flashAPIErrors, setErrorMessage } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { EngineLogic } from '../engine'; +import { NO_SEARCH_KEY_ERROR } from './i18n'; + import { ActiveField } from './types'; interface InitialFieldValues { @@ -84,7 +86,12 @@ export const SearchUILogic = kea> listeners: ({ actions }) => ({ loadFieldData: async () => { const { http } = HttpLogic.values; - const { engineName } = EngineLogic.values; + const { searchKey, engineName } = EngineLogic.values; + + if (!searchKey) { + setErrorMessage(NO_SEARCH_KEY_ERROR(engineName)); + return; + } const url = `/api/app_search/engines/${engineName}/search_ui/field_config`; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.test.ts index 8d99ffc514b3f..9a005649e7dc9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.test.ts @@ -33,7 +33,7 @@ describe('generatePreviewUrl', () => { empty2: [''], // Empty fields should be stripped }) ).toEqual( - 'http://localhost:3002/as/engines/national-parks-demo/reference_application/preview?facets[]=baz&facets[]=qux&sortFields[]=quux&sortFields[]=quuz&titleField=foo&urlField=bar' + 'http://localhost:3002/as/engines/national-parks-demo/search_experience/preview?facets[]=baz&facets[]=qux&sortFields[]=quux&sortFields[]=quuz&titleField=foo&urlField=bar' ); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.ts index 72d90514ea0a0..6c57df0d66a2a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/search_ui/utils.ts @@ -15,7 +15,7 @@ export const generatePreviewUrl = (query: ParsedQuery) => { return queryString.stringifyUrl( { query, - url: getAppSearchUrl(`/engines/${engineName}/reference_application/preview`), + url: getAppSearchUrl(`/engines/${engineName}/search_experience/preview`), }, { arrayFormat: 'bracket', skipEmptyString: true } );