[App Search] Meta engines schema view (#100087)

* Set up TruncatedEnginesList component

- Used for listing source engines
- New in Kibana: now links to source engine schema pages for easier schema fixes!

* Add meta engines schema active fields table

* Render meta engine schema conflicts table & warning callout

* Update x-pack/plugins/enterprise_search/public/applications/app_search/components/schema/components/truncated_engines_list.tsx

Co-authored-by: Jason Stoltzfus <jastoltz24@gmail.com>

Co-authored-by: Jason Stoltzfus <jastoltz24@gmail.com>
This commit is contained in:
Constance 2021-05-14 15:13:26 -07:00 committed by GitHub
parent 90db9dfae8
commit 091ca4384a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 469 additions and 5 deletions

View file

@ -8,3 +8,6 @@
export { SchemaCallouts } from './schema_callouts';
export { SchemaTable } from './schema_table';
export { EmptyState } from './empty_state';
export { MetaEnginesSchemaTable } from './meta_engines_schema_table';
export { MetaEnginesConflictsTable } from './meta_engines_conflicts_table';
export { TruncatedEnginesList } from './truncated_engines_list';

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../__mocks__';
import React from 'react';
import { mount } from 'enzyme';
import { EuiTable, EuiTableHeaderCell, EuiTableRow } from '@elastic/eui';
import { MetaEnginesConflictsTable } from './';
describe('MetaEnginesConflictsTable', () => {
const values = {
conflictingFields: {
hello_field: {
text: ['engine1'],
number: ['engine2'],
date: ['engine3'],
},
world_field: {
text: ['engine1'],
location: ['engine2', 'engine3', 'engine4'],
},
},
};
setMockValues(values);
const wrapper = mount(<MetaEnginesConflictsTable />);
const fieldNames = wrapper.find('EuiTableRowCell[data-test-subj="fieldName"]');
const fieldTypes = wrapper.find('EuiTableRowCell[data-test-subj="fieldTypes"]');
const engines = wrapper.find('EuiTableRowCell[data-test-subj="enginesPerFieldType"]');
it('renders', () => {
expect(wrapper.find(EuiTable)).toHaveLength(1);
expect(wrapper.find(EuiTableHeaderCell).at(0).text()).toEqual('Field name');
expect(wrapper.find(EuiTableHeaderCell).at(1).text()).toEqual('Field type conflicts');
expect(wrapper.find(EuiTableHeaderCell).at(2).text()).toEqual('Engines');
});
it('renders a rowspan on the initial field name column so that it stretches to all associated field conflict rows', () => {
expect(fieldNames).toHaveLength(2);
expect(fieldNames.at(0).prop('rowSpan')).toEqual(3);
expect(fieldNames.at(1).prop('rowSpan')).toEqual(2);
});
it('renders a row for each field type conflict and the engines that have that field type', () => {
expect(wrapper.find(EuiTableRow)).toHaveLength(5);
expect(fieldNames.at(0).text()).toEqual('hello_field');
expect(fieldTypes.at(0).text()).toEqual('text');
expect(engines.at(0).text()).toEqual('engine1');
expect(fieldTypes.at(1).text()).toEqual('number');
expect(engines.at(1).text()).toEqual('engine2');
expect(fieldTypes.at(2).text()).toEqual('date');
expect(engines.at(2).text()).toEqual('engine3');
expect(fieldNames.at(1).text()).toEqual('world_field');
expect(fieldTypes.at(3).text()).toEqual('text');
expect(engines.at(3).text()).toEqual('engine1');
expect(fieldTypes.at(4).text()).toEqual('location');
expect(engines.at(4).text()).toEqual('engine2, engine3, +1');
});
});

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import {
EuiTable,
EuiTableHeader,
EuiTableHeaderCell,
EuiTableBody,
EuiTableRow,
EuiTableRowCell,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FIELD_NAME } from '../../../../shared/schema/constants';
import { ENGINES_TITLE } from '../../engines';
import { MetaEngineSchemaLogic } from '../schema_meta_engine_logic';
import { TruncatedEnginesList } from './';
export const MetaEnginesConflictsTable: React.FC = () => {
const { conflictingFields } = useValues(MetaEngineSchemaLogic);
return (
<EuiTable tableLayout="auto">
<EuiTableHeader>
<EuiTableHeaderCell>{FIELD_NAME}</EuiTableHeaderCell>
<EuiTableHeaderCell>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.fieldTypeConflicts',
{ defaultMessage: 'Field type conflicts' }
)}
</EuiTableHeaderCell>
<EuiTableHeaderCell>{ENGINES_TITLE}</EuiTableHeaderCell>
</EuiTableHeader>
<EuiTableBody>
{Object.entries(conflictingFields).map(([fieldName, conflicts]) =>
Object.entries(conflicts).map(([fieldType, engines], i) => {
const isFirstRow = i === 0;
return (
<EuiTableRow key={`${fieldName}-${fieldType}`}>
{isFirstRow && (
<EuiTableRowCell
rowSpan={Object.values(conflicts).length}
data-test-subj="fieldName"
>
<code>{fieldName}</code>
</EuiTableRowCell>
)}
<EuiTableRowCell data-test-subj="fieldTypes">{fieldType}</EuiTableRowCell>
<EuiTableRowCell data-test-subj="enginesPerFieldType">
<TruncatedEnginesList engines={engines} cutoff={2} />
</EuiTableRowCell>
</EuiTableRow>
);
})
)}
</EuiTableBody>
</EuiTable>
);
};

View file

@ -0,0 +1,63 @@
/*
* 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 } from '../../../../__mocks__';
import React from 'react';
import { mount } from 'enzyme';
import { EuiTable, EuiTableHeaderCell, EuiTableRow, EuiTableRowCell } from '@elastic/eui';
import { MetaEnginesSchemaTable } from './';
describe('MetaEnginesSchemaTable', () => {
const values = {
schema: {
some_text_field: 'text',
some_number_field: 'number',
},
fields: {
some_text_field: {
text: ['engine1', 'engine2'],
},
some_number_field: {
number: ['engine1', 'engine2', 'engine3', 'engine4'],
},
},
};
setMockValues(values);
const wrapper = mount(<MetaEnginesSchemaTable />);
const fieldNames = wrapper.find('EuiTableRowCell[data-test-subj="fieldName"]');
const engines = wrapper.find('EuiTableRowCell[data-test-subj="engines"]');
const fieldTypes = wrapper.find('EuiTableRowCell[data-test-subj="fieldType"]');
it('renders', () => {
expect(wrapper.find(EuiTable)).toHaveLength(1);
expect(wrapper.find(EuiTableHeaderCell).at(0).text()).toEqual('Field name');
expect(wrapper.find(EuiTableHeaderCell).at(1).text()).toEqual('Engines');
expect(wrapper.find(EuiTableHeaderCell).at(2).text()).toEqual('Field type');
});
it('always renders an initial ID row', () => {
expect(wrapper.find('code').at(0).text()).toEqual('id');
expect(wrapper.find(EuiTableRowCell).at(1).text()).toEqual('All');
});
it('renders subsequent table rows for each schema field', () => {
expect(wrapper.find(EuiTableRow)).toHaveLength(3);
expect(fieldNames.at(0).text()).toEqual('some_text_field');
expect(engines.at(0).text()).toEqual('engine1, engine2');
expect(fieldTypes.at(0).text()).toEqual('text');
expect(fieldNames.at(1).text()).toEqual('some_number_field');
expect(engines.at(1).text()).toEqual('engine1, engine2, engine3, +1');
expect(fieldTypes.at(1).text()).toEqual('number');
});
});

View file

@ -0,0 +1,78 @@
/*
* 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 } from 'kea';
import {
EuiTable,
EuiTableHeader,
EuiTableHeaderCell,
EuiTableBody,
EuiTableRow,
EuiTableRowCell,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FIELD_NAME, FIELD_TYPE } from '../../../../shared/schema/constants';
import { ENGINES_TITLE } from '../../engines';
import { MetaEngineSchemaLogic } from '../schema_meta_engine_logic';
import { TruncatedEnginesList } from './';
export const MetaEnginesSchemaTable: React.FC = () => {
const { schema, fields } = useValues(MetaEngineSchemaLogic);
return (
<EuiTable tableLayout="auto">
<EuiTableHeader>
<EuiTableHeaderCell>{FIELD_NAME}</EuiTableHeaderCell>
<EuiTableHeaderCell>{ENGINES_TITLE}</EuiTableHeaderCell>
<EuiTableHeaderCell align="right">{FIELD_TYPE}</EuiTableHeaderCell>
</EuiTableHeader>
<EuiTableBody>
<EuiTableRow>
<EuiTableRowCell>
<EuiText color="subdued">
<code>id</code>
</EuiText>
</EuiTableRowCell>
<EuiTableRowCell>
<EuiText color="subdued" size="s">
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.allEngines',
{ defaultMessage: 'All' }
)}
</EuiText>
</EuiTableRowCell>
<EuiTableRowCell aria-hidden />
</EuiTableRow>
{Object.keys(fields).map((fieldName) => {
const fieldType = schema[fieldName];
const engines = fields[fieldName][fieldType];
return (
<EuiTableRow key={fieldName}>
<EuiTableRowCell data-test-subj="fieldName">
<code>{fieldName}</code>
</EuiTableRowCell>
<EuiTableRowCell data-test-subj="engines">
<TruncatedEnginesList engines={engines} />
</EuiTableRowCell>
<EuiTableRowCell align="right" data-test-subj="fieldType">
{fieldType}
</EuiTableRowCell>
</EuiTableRow>
);
})}
</EuiTableBody>
</EuiTable>
);
};

View file

@ -0,0 +1,41 @@
/*
* 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 { TruncatedEnginesList } from './';
describe('TruncatedEnginesList', () => {
it('renders a list of engines with links to their schema pages', () => {
const wrapper = shallow(<TruncatedEnginesList engines={['engine1', 'engine2', 'engine3']} />);
expect(wrapper.find('[data-test-subj="displayedEngine"]')).toHaveLength(3);
expect(wrapper.find('[data-test-subj="displayedEngine"]').first().prop('to')).toEqual(
'/engines/engine1/schema'
);
});
it('renders a tooltip when the number of engines is greater than the cutoff', () => {
const wrapper = shallow(
<TruncatedEnginesList engines={['engine1', 'engine2', 'engine3']} cutoff={1} />
);
expect(wrapper.find('[data-test-subj="displayedEngine"]')).toHaveLength(1);
expect(wrapper.find('[data-test-subj="hiddenEnginesTooltip"]')).toHaveLength(1);
expect(wrapper.find('[data-test-subj="hiddenEnginesTooltip"]').prop('content')).toEqual(
'engine2, engine3'
);
});
it('does not render if no engines are passed', () => {
const wrapper = shallow(<TruncatedEnginesList engines={[]} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
});

View file

@ -0,0 +1,60 @@
/*
* 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, { Fragment } from 'react';
import { EuiText, EuiToolTip, EuiButtonEmpty } from '@elastic/eui';
import { EuiLinkTo } from '../../../../shared/react_router_helpers';
import { ENGINE_SCHEMA_PATH } from '../../../routes';
import { generateEncodedPath } from '../../../utils/encode_path_params';
interface Props {
engines?: string[];
cutoff?: number;
}
export const TruncatedEnginesList: React.FC<Props> = ({ engines, cutoff = 3 }) => {
if (!engines?.length) return null;
const displayedEngines = engines.slice(0, cutoff);
const hiddenEngines = engines.slice(cutoff);
const SEPARATOR = ', ';
return (
<EuiText size="s">
{displayedEngines.map((engineName, i) => {
const isLast = i === displayedEngines.length - 1;
return (
<Fragment key={engineName}>
<EuiLinkTo
to={generateEncodedPath(ENGINE_SCHEMA_PATH, { engineName })}
data-test-subj="displayedEngine"
>
{engineName}
</EuiLinkTo>
{!isLast ? SEPARATOR : ''}
</Fragment>
);
})}
{hiddenEngines.length > 0 && (
<>
{SEPARATOR}
<EuiToolTip
position="bottom"
content={hiddenEngines.join(SEPARATOR)}
data-test-subj="hiddenEnginesTooltip"
>
<EuiButtonEmpty size="xs" flush="both">
+{hiddenEngines.length}
</EuiButtonEmpty>
</EuiToolTip>
</>
)}
</EuiText>
);
};

View file

@ -12,8 +12,12 @@ import React from 'react';
import { shallow } from 'enzyme';
import { EuiCallOut } from '@elastic/eui';
import { Loading } from '../../../../shared/loading';
import { MetaEnginesSchemaTable, MetaEnginesConflictsTable } from '../components';
import { MetaEngineSchema } from './';
describe('MetaEngineSchema', () => {
@ -33,8 +37,7 @@ describe('MetaEngineSchema', () => {
it('renders', () => {
const wrapper = shallow(<MetaEngineSchema />);
expect(wrapper.isEmptyRender()).toBe(false);
// TODO: Check for schema components
expect(wrapper.find(MetaEnginesSchemaTable)).toHaveLength(1);
});
it('calls loadSchema on mount', () => {
@ -49,4 +52,12 @@ describe('MetaEngineSchema', () => {
expect(wrapper.find(Loading)).toHaveLength(1);
});
it('renders an inactive fields callout & table when source engines have schema conflicts', () => {
setMockValues({ ...values, hasConflicts: true, conflictingFieldsCount: 5 });
const wrapper = shallow(<MetaEngineSchema />);
expect(wrapper.find(EuiCallOut)).toHaveLength(1);
expect(wrapper.find(MetaEnginesConflictsTable)).toHaveLength(1);
});
});

View file

@ -9,17 +9,19 @@ import React, { useEffect } from 'react';
import { useValues, useActions } from 'kea';
import { EuiPageHeader, EuiPageContentBody } from '@elastic/eui';
import { EuiPageHeader, EuiPageContentBody, EuiCallOut, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FlashMessages } from '../../../../shared/flash_messages';
import { Loading } from '../../../../shared/loading';
import { DataPanel } from '../../data_panel';
import { MetaEnginesSchemaTable, MetaEnginesConflictsTable } from '../components';
import { MetaEngineSchemaLogic } from '../schema_meta_engine_logic';
export const MetaEngineSchema: React.FC = () => {
const { loadSchema } = useActions(MetaEngineSchemaLogic);
const { dataLoading } = useValues(MetaEngineSchemaLogic);
const { dataLoading, hasConflicts, conflictingFieldsCount } = useValues(MetaEngineSchemaLogic);
useEffect(() => {
loadSchema();
@ -40,7 +42,75 @@ export const MetaEngineSchema: React.FC = () => {
)}
/>
<FlashMessages />
<EuiPageContentBody>TODO</EuiPageContentBody>
<EuiPageContentBody>
{hasConflicts && (
<>
<EuiCallOut
iconType="alert"
color="warning"
title={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.conflictsCalloutTitle',
{
defaultMessage:
'{conflictingFieldsCount, plural, one {# field is} other {# fields are}} not searchable',
values: { conflictingFieldsCount },
}
)}
>
<p>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.conflictsCalloutDescription',
{
defaultMessage:
'The field(s) have an inconsistent field-type across the source engines that make up this meta engine. Apply a consistent field-type from the source engines to make these fields searchable.',
}
)}
</p>
</EuiCallOut>
<EuiSpacer />
</>
)}
<DataPanel
hasBorder
title={
<h2>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.activeFieldsTitle',
{ defaultMessage: 'Active fields' }
)}
</h2>
}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.activeFieldsDescription',
{ defaultMessage: 'Fields which belong to one or more engine.' }
)}
>
<MetaEnginesSchemaTable />
</DataPanel>
<EuiSpacer />
{hasConflicts && (
<DataPanel
hasBorder
title={
<h2>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.inactiveFieldsTitle',
{ defaultMessage: 'Inactive fields' }
)}
</h2>
}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.schema.metaEngine.inactiveFieldsDescription',
{
defaultMessage:
'These fields have type conflicts. To activate these fields, change types in the source engines to match.',
}
)}
>
<MetaEnginesConflictsTable />
</DataPanel>
)}
</EuiPageContentBody>
</>
);
};