diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.test.tsx new file mode 100644 index 0000000000000..ea658c741b8a0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; + +import { EmptyState } from './'; + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow() + .find(EuiEmptyPrompt) + .dive(); + + expect(wrapper.find('h2').text()).toEqual('Create a schema'); + expect(wrapper.find(EuiButton).prop('href')).toEqual( + expect.stringContaining('#indexing-documents-guide-schema') + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.tsx new file mode 100644 index 0000000000000..6d7dd198d5eef --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/empty_state.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiPanel, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { DOCS_PREFIX } from '../../../routes'; + +export const EmptyState: React.FC = () => { + return ( + + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.schema.empty.title', { + defaultMessage: 'Create a schema', + })} + + } + body={ +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.engine.schema.empty.description', { + defaultMessage: + 'Create schema fields in advance, or index some documents and a schema will be created for you.', + })} +

+ } + actions={ + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.schema.empty.buttonLabel', { + defaultMessage: 'Read the indexing schema guide', + })} + + } + /> +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/index.ts new file mode 100644 index 0000000000000..7da44849b5bc0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SchemaCallouts } from './schema_callouts'; +export { SchemaTable } from './schema_table'; +export { EmptyState } from './empty_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_callouts.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_callouts.test.tsx new file mode 100644 index 0000000000000..5bb08a6c8859a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_callouts.test.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues, setMockActions } from '../../../../__mocks__'; +import '../../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { SchemaErrorsCallout } from '../../../../shared/schema'; + +import { + UnsearchedFieldsCallout, + UnconfirmedFieldsCallout, + ConfirmSchemaButton, +} from './schema_callouts'; + +import { SchemaCallouts } from './'; + +describe('SchemaCallouts', () => { + const values = { + hasUnconfirmedFields: false, + hasNewUnsearchedFields: false, + mostRecentIndexJob: { + hasErrors: false, + activeReindexJobId: 'some-id', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + }); + + it('renders nothing if there is nothing to call out', () => { + const wrapper = shallow(); + + expect(wrapper.text()).toBeFalsy(); + }); + + it('renders a schema errors callout if the most recent index job had errors', () => { + setMockValues({ + ...values, + mostRecentIndexJob: { + hasErrors: true, + activeReindexJobId: '12345', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(SchemaErrorsCallout)).toHaveLength(1); + expect(wrapper.find(SchemaErrorsCallout).prop('viewErrorsPath')).toEqual( + '/engines/some-engine/schema/reindex_job/12345' + ); + }); + + it('renders an unsearched fields callout if the schema has new unconfirmed & unsearched fields', () => { + setMockValues({ + ...values, + hasUnconfirmedFields: true, + hasNewUnsearchedFields: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(UnsearchedFieldsCallout)).toHaveLength(1); + }); + + it('renders an unconfirmed fields callout if the schema has unconfirmed fields', () => { + setMockValues({ + ...values, + hasUnconfirmedFields: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(UnconfirmedFieldsCallout)).toHaveLength(1); + }); + + describe('UnsearchedFieldsCallout', () => { + it('renders an info callout about unsearched fields with a link to the relevance tuning page', () => { + const wrapper = shallow(); + + expect(wrapper.prop('title')).toEqual( + 'Recently added fields are not being searched by default' + ); + expect(wrapper.find('[data-test-subj="relevanceTuningButtonLink"]').prop('to')).toEqual( + '/engines/some-engine/relevance_tuning' + ); + }); + }); + + describe('UnconfirmedFieldsCallout', () => { + it('renders an info callout about unconfirmed fields', () => { + const wrapper = shallow(); + + expect(wrapper.prop('title')).toEqual("You've recently added new schema fields"); + }); + }); + + describe('ConfirmSchemaButton', () => { + const actions = { updateSchema: jest.fn() }; + + beforeEach(() => { + setMockValues({ isUpdating: false }); + setMockActions(actions); + }); + + it('allows users to confirm schema without changes from the callouts', () => { + const wrapper = shallow(); + + wrapper.simulate('click'); + expect(actions.updateSchema).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_callouts.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_callouts.tsx new file mode 100644 index 0000000000000..50a2ee5c83abe --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_callouts.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues, useActions } from 'kea'; + +import { EuiCallOut, EuiButton, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { EuiButtonTo } from '../../../../shared/react_router_helpers'; +import { SchemaErrorsCallout } from '../../../../shared/schema'; +import { ENGINE_RELEVANCE_TUNING_PATH, ENGINE_REINDEX_JOB_PATH } from '../../../routes'; +import { generateEnginePath } from '../../engine'; + +import { SchemaLogic } from '../schema_logic'; + +export const SchemaCallouts: React.FC = () => { + const { + hasUnconfirmedFields, + hasNewUnsearchedFields, + mostRecentIndexJob: { hasErrors, activeReindexJobId }, + } = useValues(SchemaLogic); + + return ( + <> + {hasErrors && ( + <> + + + + )} + {hasUnconfirmedFields && ( + <> + {hasNewUnsearchedFields ? : } + + + )} + + ); +}; + +export const UnsearchedFieldsCallout: React.FC = () => ( + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.schema.unsearchedFields.description', + { + defaultMessage: + 'If these new fields should be searchable, update your search settings to include them. If you want them to remain unsearchable, confirm your new field types to dismiss this alert.', + } + )} +

+ + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.schema.unsearchedFields.searchSettingsButtonLabel', + { defaultMessage: 'Update search settings' } + )} + + + + + + +
+); + +export const UnconfirmedFieldsCallout: React.FC = () => ( + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.schema.unconfirmedFields.description', + { + defaultMessage: + 'Set your new schema field(s) to their correct or expected types, and then confirm your field types.', + } + )} +

+ +
+); + +export const ConfirmSchemaButton: React.FC = () => { + const { updateSchema } = useActions(SchemaLogic); + const { isUpdating } = useValues(SchemaLogic); + + return ( + updateSchema()} + data-test-subj="confirmSchemaTypesButton" + > + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.schema.confirmSchemaButtonLabel', { + defaultMessage: 'Confirm types', + })} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_table.test.tsx new file mode 100644 index 0000000000000..c8b0bb7ddbac5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_table.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiTable, EuiTableHeaderCell, EuiTableRow, EuiHealth } from '@elastic/eui'; + +import { SchemaFieldTypeSelect } from '../../../../shared/schema'; + +import { SchemaTable } from './'; + +describe('SchemaTable', () => { + const values = { + schema: {}, + unconfirmedFields: [], + }; + const actions = { + updateSchemaFieldType: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTable)).toHaveLength(1); + expect(wrapper.find(EuiTableHeaderCell).first().prop('children')).toEqual('Field name'); + expect(wrapper.find(EuiTableHeaderCell).last().prop('children')).toEqual('Field type'); + }); + + it('always renders an initial ID row (with no field type select)', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTableRow)).toHaveLength(1); + expect(wrapper.find('code').text()).toEqual('id'); + expect(wrapper.find(SchemaFieldTypeSelect)).toHaveLength(0); + }); + + it('renders subsequent table rows for each schema field', () => { + setMockValues({ + ...values, + schema: { + some_text_field: 'text', + some_number_field: 'number', + some_date_field: 'date', + some_location_field: 'geolocation', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiTableRow)).toHaveLength(5); + + expect(wrapper.find('code').at(1).text()).toEqual('some_text_field'); + expect(wrapper.find(SchemaFieldTypeSelect).at(0).prop('fieldType')).toEqual('text'); + + expect(wrapper.find('code').last().text()).toEqual('some_location_field'); + expect(wrapper.find(SchemaFieldTypeSelect).last().prop('fieldType')).toEqual('geolocation'); + }); + + it('renders a recently added status if a field has been recently added', () => { + setMockValues({ + ...values, + schema: { + some_new_field: 'text', + }, + unconfirmedFields: ['some_new_field'], + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiHealth)).toHaveLength(1); + expect(wrapper.find(EuiHealth).childAt(0).prop('children')).toEqual('Recently added'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_table.tsx new file mode 100644 index 0000000000000..d9187bb65adf0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/schema_table.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues, useActions } from 'kea'; + +import { + EuiTable, + EuiTableHeader, + EuiTableHeaderCell, + EuiTableBody, + EuiTableRow, + EuiTableRowCell, + EuiHealth, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { SchemaFieldTypeSelect } from '../../../../shared/schema'; +import { FIELD_NAME, FIELD_TYPE } from '../../../../shared/schema/constants'; + +import { SchemaLogic } from '../schema_logic'; + +export const SchemaTable: React.FC = () => { + const { schema, unconfirmedFields } = useValues(SchemaLogic); + const { updateSchemaFieldType } = useActions(SchemaLogic); + + return ( + + + {FIELD_NAME} + + {FIELD_TYPE} + + + + + + id + + + + + + {Object.entries(schema).map(([fieldName, fieldType]) => { + const isRecentlyAdded = unconfirmedFields.length && unconfirmedFields.includes(fieldName); + + return ( + + + {fieldName} + + {isRecentlyAdded ? ( + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.schema.unconfirmedFieldLabel', + { defaultMessage: 'Recently added' } + )} + + + + ) : ( + + )} + + + + + ); + })} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_base_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_base_logic.test.ts index e5dbf97b971d9..2f5788278aa0b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_base_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/schema_base_logic.test.ts @@ -72,6 +72,17 @@ describe('SchemaBaseLogic', () => { describe('listeners', () => { describe('loadSchema', () => { + it('sets dataLoading to true', () => { + mount({ dataLoading: false }); + + SchemaBaseLogic.actions.loadSchema(); + + expect(SchemaBaseLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: true, + }); + }); + it('should make an API call and then set schema state', async () => { http.get.mockReturnValueOnce(Promise.resolve(MOCK_RESPONSE)); mount(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx index fd6a742d00cda..23d1480e5dca9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.test.tsx @@ -15,15 +15,26 @@ import { shallow } from 'enzyme'; import { EuiPageHeader, EuiButton } from '@elastic/eui'; import { Loading } from '../../../../shared/loading'; +import { SchemaAddFieldModal } from '../../../../shared/schema'; + +import { SchemaCallouts, SchemaTable, EmptyState } from '../components'; import { Schema } from './'; describe('Schema', () => { const values = { dataLoading: false, + hasSchema: true, + hasSchemaChanged: false, + isUpdating: false, + isModalOpen: false, }; const actions = { loadSchema: jest.fn(), + updateSchema: jest.fn(), + addSchemaField: jest.fn(), + openModal: jest.fn(), + closeModal: jest.fn(), }; beforeEach(() => { @@ -35,8 +46,8 @@ describe('Schema', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.isEmptyRender()).toBe(false); - // TODO: Check for schema components + expect(wrapper.find(SchemaCallouts)).toHaveLength(1); + expect(wrapper.find(SchemaTable)).toHaveLength(1); }); it('calls loadSchema on mount', () => { @@ -52,14 +63,79 @@ describe('Schema', () => { expect(wrapper.find(Loading)).toHaveLength(1); }); - it('renders page action buttons', () => { - const wrapper = shallow() - .find(EuiPageHeader) - .dive() - .children() - .dive(); + it('renders an empty state', () => { + setMockValues({ ...values, hasSchema: false }); + const wrapper = shallow(); + + expect(wrapper.find(EmptyState)).toHaveLength(1); + }); + + describe('page action buttons', () => { + const subject = () => + shallow() + .find(EuiPageHeader) + .dive() + .children() + .dive(); + + it('renders', () => { + const wrapper = subject(); + expect(wrapper.find(EuiButton)).toHaveLength(2); + }); + + it('renders loading/disabled state when schema is updating', () => { + setMockValues({ isUpdating: true }); + const wrapper = subject(); + + expect(wrapper.find('[data-test-subj="updateSchemaButton"]').prop('isLoading')).toBe(true); + expect(wrapper.find('[data-test-subj="addSchemaFieldModalButton"]').prop('disabled')).toBe( + true + ); + }); + + describe('add button', () => { + it('opens the add schema field modal', () => { + const wrapper = subject(); + + wrapper.find('[data-test-subj="addSchemaFieldModalButton"]').simulate('click'); + expect(actions.openModal).toHaveBeenCalled(); + }); + }); + + describe('update button', () => { + describe('when nothing on the page has changed', () => { + it('is disabled', () => { + const wrapper = subject(); + + expect(wrapper.find('[data-test-subj="updateSchemaButton"]').prop('disabled')).toBe(true); + }); + }); + + describe('when schema has been changed locally', () => { + it('is enabled', () => { + setMockValues({ ...values, hasSchemaChanged: true }); + const wrapper = subject(); + + expect(wrapper.find('[data-test-subj="updateSchemaButton"]').prop('disabled')).toBe( + false + ); + }); + + it('calls updateSchema on click', () => { + setMockValues({ ...values, hasSchemaChanged: true }); + const wrapper = subject(); + + wrapper.find('[data-test-subj="updateSchemaButton"]').simulate('click'); + expect(actions.updateSchema).toHaveBeenCalled(); + }); + }); + }); + }); + + it('renders a modal that lets a user add a new schema field', () => { + setMockValues({ isModalOpen: true }); + const wrapper = shallow(); - expect(wrapper.find(EuiButton)).toHaveLength(2); - // TODO: Expect click actions + expect(wrapper.find(SchemaAddFieldModal)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx index 21dd52b04f4a7..7bc995b16468a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/views/schema.tsx @@ -14,12 +14,18 @@ import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../../shared/flash_messages'; import { Loading } from '../../../../shared/loading'; +import { SchemaAddFieldModal } from '../../../../shared/schema'; +import { SchemaCallouts, SchemaTable, EmptyState } from '../components'; import { SchemaLogic } from '../schema_logic'; export const Schema: React.FC = () => { - const { loadSchema } = useActions(SchemaLogic); - const { dataLoading } = useValues(SchemaLogic); + const { loadSchema, updateSchema, addSchemaField, openModal, closeModal } = useActions( + SchemaLogic + ); + const { dataLoading, isUpdating, hasSchema, hasSchemaChanged, isModalOpen } = useValues( + SchemaLogic + ); useEffect(() => { loadSchema(); @@ -38,13 +44,24 @@ export const Schema: React.FC = () => { { defaultMessage: 'Add new fields or change the types of existing ones.' } )} rightSideItems={[ - + updateSchema()} + data-test-subj="updateSchemaButton" + > {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.schema.updateSchemaButtonLabel', { defaultMessage: 'Update types' } )} , - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.schema.createSchemaFieldButtonLabel', { defaultMessage: 'Create a schema field' } @@ -53,7 +70,13 @@ export const Schema: React.FC = () => { ]} /> - TODO + + + {hasSchema ? : } + {isModalOpen && ( + + )} + ); }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f1c7af87a80d6..23a559b9ce374 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8162,6 +8162,7 @@ "xpack.enterpriseSearch.appSearch.engine.sampleEngineBadge": "サンプルエンジン", "xpack.enterpriseSearch.appSearch.engine.schema.conflicts": "スキーマ競合", "xpack.enterpriseSearch.appSearch.engine.schema.title": "スキーマ", + "xpack.enterpriseSearch.appSearch.engine.schema.unconfirmedFieldLabel": "最近追加された項目", "xpack.enterpriseSearch.appSearch.engine.schema.unconfirmedFields": "新しい未確認のフィールド", "xpack.enterpriseSearch.appSearch.engine.searchUI.title": "Search UI", "xpack.enterpriseSearch.appSearch.engine.synonyms.title": "同義語", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 935bad1eee5c7..5a33399f2dada 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8231,6 +8231,7 @@ "xpack.enterpriseSearch.appSearch.engine.sampleEngineBadge": "样本引擎", "xpack.enterpriseSearch.appSearch.engine.schema.conflicts": "架构冲突", "xpack.enterpriseSearch.appSearch.engine.schema.title": "架构", + "xpack.enterpriseSearch.appSearch.engine.schema.unconfirmedFieldLabel": "最近添加", "xpack.enterpriseSearch.appSearch.engine.schema.unconfirmedFields": "新的未确认字段", "xpack.enterpriseSearch.appSearch.engine.searchUI.title": "搜索 UI", "xpack.enterpriseSearch.appSearch.engine.synonyms.title": "同义词",