Create a Table view for index mappings (#178360)

Index mappings should be viewable in a human-readable format that is not
JSON. We could probably leverage the existing mappings editor UI that we
use when composing index and component templates to do this. In this PR,
we addressed the following items:

- [x] Show a read-only mapping view
- [x] Add a search bar to search for specific fields




7211e778-b33b-4b2c-93d8-6b9b7d65956e

---------

Co-authored-by: Elena Stoeva <59341489+ElenaStoeva@users.noreply.github.com>
This commit is contained in:
Saikat Sarkar 2024-03-18 11:49:28 -06:00 committed by GitHub
parent ad299dee0f
commit 3a5136ac1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 390 additions and 182 deletions

View file

@ -43,6 +43,9 @@ export interface IndexDetailsPageTestBed extends TestBed {
getDocsLinkHref: () => string;
isErrorDisplayed: () => boolean;
clickErrorReloadButton: () => Promise<void>;
getTreeViewContent: () => string;
clickToggleViewButton: () => Promise<void>;
isSearchBarDisabled: () => boolean;
};
settings: {
getCodeBlockContent: () => string;
@ -195,6 +198,18 @@ export const setup = async ({
});
component.update();
},
getTreeViewContent: () => {
return find('@timestampField-fieldName').text();
},
clickToggleViewButton: async () => {
await act(async () => {
find('indexDetailsMappingsToggleViewButton').simulate('click');
});
component.update();
},
isSearchBarDisabled: () => {
return find('DocumentFieldsSearch').prop('disabled');
},
};
const settings = {

View file

@ -476,6 +476,24 @@ describe('<IndexDetailsPage />', () => {
expect(tabContent).toEqual(JSON.stringify(testIndexMappings, null, 2));
});
it('displays the mappings in the table view', async () => {
await testBed.actions.clickIndexDetailsTab(IndexDetailsSection.Mappings);
await testBed.actions.mappings.clickToggleViewButton();
const tabContent = testBed.actions.mappings.getTreeViewContent();
expect(tabContent).toContain('@timestamp');
});
it('search bar is enabled in JSON view', async () => {
await testBed.actions.clickIndexDetailsTab(IndexDetailsSection.Mappings);
expect(testBed.actions.mappings.isSearchBarDisabled()).toBe(true);
});
it('search bar is disabled in Tree view', async () => {
await testBed.actions.clickIndexDetailsTab(IndexDetailsSection.Mappings);
await testBed.actions.mappings.clickToggleViewButton();
expect(testBed.actions.mappings.isSearchBarDisabled()).toBe(false);
});
it('sets the docs link href from the documentation service', async () => {
await testBed.actions.clickIndexDetailsTab(IndexDetailsSection.Mappings);
const docsLinkHref = testBed.actions.mappings.getDocsLinkHref();

View file

@ -7,10 +7,11 @@
import React from 'react';
import { EuiText, EuiLink, EuiFlexGroup, EuiFlexItem, EuiFieldSearch } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { documentationService } from '../../../../services/documentation';
import { DocumentFieldsSearch } from './document_fields_search';
interface Props {
searchValue: string;
@ -37,34 +38,7 @@ export const DocumentFieldsHeader = React.memo(({ searchValue, onSearchChange }:
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFieldSearch
style={{ minWidth: '350px' }}
placeholder={i18n.translate(
'xpack.idxMgmt.mappingsEditor.documentFields.searchFieldsPlaceholder',
{
defaultMessage: 'Search fields',
}
)}
value={searchValue}
onChange={(e) => {
// Temporary fix until EUI fixes the contract
// See my comment https://github.com/elastic/eui/pull/2723/files#r366725059
if (typeof e === 'string') {
onSearchChange(e);
} else {
onSearchChange(e.target.value);
}
}}
aria-label={i18n.translate(
'xpack.idxMgmt.mappingsEditor.documentFields.searchFieldsAriaLabel',
{
defaultMessage: 'Search mapped fields',
}
)}
/>
</EuiFlexItem>
<DocumentFieldsSearch searchValue={searchValue} onSearchChange={onSearchChange} />
</EuiFlexGroup>
);
});

View file

@ -0,0 +1,53 @@
/*
* 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 { EuiFieldSearch, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface Props {
searchValue: string;
onSearchChange(value: string): void;
disabled?: boolean;
}
export const DocumentFieldsSearch = React.memo(
({ searchValue, onSearchChange, disabled = false }: Props) => {
return (
<EuiFlexItem grow={false}>
<EuiFieldSearch
disabled={disabled}
style={{ minWidth: '350px' }}
placeholder={i18n.translate(
'xpack.idxMgmt.mappingsEditor.documentFields.searchFieldsPlaceholder',
{
defaultMessage: 'Search fields',
}
)}
value={searchValue}
onChange={(e) => {
// Temporary fix until EUI fixes the contract
// See my comment https://github.com/elastic/eui/pull/2723/files#r366725059
if (typeof e === 'string') {
onSearchChange(e);
} else {
onSearchChange(e.target.value);
}
}}
aria-label={i18n.translate(
'xpack.idxMgmt.mappingsEditor.documentFields.searchFieldsAriaLabel',
{
defaultMessage: 'Search mapped fields',
}
)}
data-test-subj="DocumentFieldsSearch"
/>
</EuiFlexItem>
);
}
);

View file

@ -33,7 +33,7 @@ import { DocLinksStart } from './shared_imports';
type TabName = 'fields' | 'runtimeFields' | 'advanced' | 'templates';
interface MappingsEditorParsedMetadata {
export interface MappingsEditorParsedMetadata {
parsedDefaultValue?: {
configuration: MappingsConfiguration;
fields: { [key: string]: Field };

View file

@ -56,7 +56,7 @@ export interface MappingsFields {
[key: string]: any;
}
export type DocumentFieldsStatus = 'idle' | 'editingField' | 'creatingField';
export type DocumentFieldsStatus = 'idle' | 'editingField' | 'creatingField' | 'disabled';
export interface DocumentFieldsState {
status: DocumentFieldsStatus;

View file

@ -8,6 +8,7 @@
import { useEffect, useMemo } from 'react';
import {
DocumentFieldsStatus,
Field,
Mappings,
MappingsConfiguration,
@ -25,16 +26,17 @@ import {
import { useMappingsState, useDispatch } from './mappings_state_context';
interface Args {
onChange: OnUpdateHandler;
onChange?: OnUpdateHandler;
value?: {
templates: MappingsTemplates;
configuration: MappingsConfiguration;
fields: { [key: string]: Field };
runtime: RuntimeFields;
};
status?: DocumentFieldsStatus;
}
export const useMappingsStateListener = ({ onChange, value }: Args) => {
export const useMappingsStateListener = ({ onChange, value, status }: Args) => {
const state = useMappingsState();
const dispatch = useDispatch();
@ -46,6 +48,12 @@ export const useMappingsStateListener = ({ onChange, value }: Args) => {
[runtimeFields]
);
const calculateStatus = (fieldStatus: string | undefined, rootLevelFields: string | any[]) => {
if (fieldStatus) return fieldStatus;
return rootLevelFields.length === 0 ? 'creatingField' : 'idle';
};
useEffect(() => {
// If we are creating a new field, but haven't entered any name
// it is valid and we can byPass its form validation (that requires a "name" to be defined)
@ -58,79 +66,81 @@ export const useMappingsStateListener = ({ onChange, value }: Args) => {
const bypassFieldFormValidation =
state.documentFields.status === 'creatingField' && emptyNameValue;
onChange({
// Output a mappings object from the user's input.
getData: () => {
// Pull the mappings properties from the current editor
const fields =
state.documentFields.editor === 'json'
? state.fieldsJsonEditor.format()
: deNormalize(state.fields);
if (onChange) {
onChange({
// Output a mappings object from the user's input.
getData: () => {
// Pull the mappings properties from the current editor
const fields =
state.documentFields.editor === 'json'
? state.fieldsJsonEditor.format()
: deNormalize(state.fields);
// Get the runtime fields
const runtime = deNormalizeRuntimeFields(state.runtimeFields);
// Get the runtime fields
const runtime = deNormalizeRuntimeFields(state.runtimeFields);
const configurationData = state.configuration.data.format();
const templatesData = state.templates.data.format();
const configurationData = state.configuration.data.format();
const templatesData = state.templates.data.format();
const output = {
...stripUndefinedValues({
...configurationData,
...templatesData,
}),
};
const output = {
...stripUndefinedValues({
...configurationData,
...templatesData,
}),
};
// Mapped fields
if (fields && Object.keys(fields).length > 0) {
output.properties = fields;
}
// Mapped fields
if (fields && Object.keys(fields).length > 0) {
output.properties = fields;
}
// Runtime fields
if (runtime && Object.keys(runtime).length > 0) {
output.runtime = runtime;
}
// Runtime fields
if (runtime && Object.keys(runtime).length > 0) {
output.runtime = runtime;
}
return Object.keys(output).length > 0 ? (output as Mappings) : undefined;
},
validate: async () => {
const configurationFormValidator =
state.configuration.submitForm !== undefined
? new Promise(async (resolve, reject) => {
try {
const { isValid } = await state.configuration.submitForm!();
resolve(isValid);
} catch (error) {
reject(error);
}
})
: Promise.resolve(true);
return Object.keys(output).length > 0 ? (output as Mappings) : undefined;
},
validate: async () => {
const configurationFormValidator =
state.configuration.submitForm !== undefined
? new Promise(async (resolve, reject) => {
try {
const { isValid } = await state.configuration.submitForm!();
resolve(isValid);
} catch (error) {
reject(error);
}
})
: Promise.resolve(true);
const templatesFormValidator =
state.templates.submitForm !== undefined
? new Promise(async (resolve, reject) => {
try {
const { isValid } = await state.templates.submitForm!();
resolve(isValid);
} catch (error) {
reject(error);
}
})
: Promise.resolve(true);
const templatesFormValidator =
state.templates.submitForm !== undefined
? new Promise(async (resolve, reject) => {
try {
const { isValid } = await state.templates.submitForm!();
resolve(isValid);
} catch (error) {
reject(error);
}
})
: Promise.resolve(true);
const promisesToValidate = [configurationFormValidator, templatesFormValidator];
const promisesToValidate = [configurationFormValidator, templatesFormValidator];
if (state.fieldForm !== undefined && !bypassFieldFormValidation) {
promisesToValidate.push(state.fieldForm.validate());
}
if (state.fieldForm !== undefined && !bypassFieldFormValidation) {
promisesToValidate.push(state.fieldForm.validate());
}
return Promise.all(promisesToValidate).then((validationArray) => {
const isValid = validationArray.every(Boolean) && state.fieldsJsonEditor.isValid;
dispatch({ type: 'validity:update', value: isValid });
return isValid;
});
},
isValid: state.isValid,
});
return Promise.all(promisesToValidate).then((validationArray) => {
const isValid = validationArray.every(Boolean) && state.fieldsJsonEditor.isValid;
dispatch({ type: 'validity:update', value: isValid });
return isValid;
});
},
isValid: state.isValid,
});
}
}, [state, onChange, dispatch]);
useEffect(() => {
@ -149,11 +159,11 @@ export const useMappingsStateListener = ({ onChange, value }: Args) => {
templates: value.templates,
fields: parsedFieldsDefaultValue,
documentFields: {
status: parsedFieldsDefaultValue.rootLevelFields.length === 0 ? 'creatingField' : 'idle',
status: calculateStatus(status, parsedFieldsDefaultValue.rootLevelFields),
editor: 'default',
},
runtimeFields: parsedRuntimeFieldsDefaultValue,
},
});
}, [value, parsedFieldsDefaultValue, dispatch, parsedRuntimeFieldsDefaultValue]);
}, [value, parsedFieldsDefaultValue, dispatch, status, parsedRuntimeFieldsDefaultValue]);
};

View file

@ -84,5 +84,5 @@ export const DetailsPageMappings: FunctionComponent<{ index: Index }> = ({ index
);
}
return <DetailsPageMappingsContent index={index} data={stringifiedData} />;
return <DetailsPageMappingsContent index={index} data={stringifiedData} jsonData={data} />;
};

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import React, { FunctionComponent } from 'react';
import {
EuiButton,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
@ -16,104 +16,242 @@ import {
EuiSpacer,
EuiText,
EuiTitle,
useEuiTheme,
EuiEmptyPrompt,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { FunctionComponent, useCallback, useMemo, useState } from 'react';
import { Index } from '../../../../../../common';
import { documentationService } from '../../../../services';
import { useAppContext } from '../../../../app_context';
import { DocumentFieldsSearch } from '../../../../components/mappings_editor/components/document_fields/document_fields_search';
import { FieldsList } from '../../../../components/mappings_editor/components/document_fields/fields';
import { SearchResult } from '../../../../components/mappings_editor/components/document_fields/search_fields';
import { extractMappingsDefinition } from '../../../../components/mappings_editor/lib';
import { MappingsEditorParsedMetadata } from '../../../../components/mappings_editor/mappings_editor';
import {
useDispatch,
useMappingsState,
} from '../../../../components/mappings_editor/mappings_state_context';
import { useMappingsStateListener } from '../../../../components/mappings_editor/use_state_listener';
import { documentationService } from '../../../../services';
export const DetailsPageMappingsContent: FunctionComponent<{ index: Index; data: string }> = ({
index,
data,
}) => {
export const DetailsPageMappingsContent: FunctionComponent<{
index: Index;
data: string;
jsonData: any;
}> = ({ index, data, jsonData }) => {
const {
services: { extensionsService },
core: { getUrlForApp },
} = useAppContext();
return (
// using "rowReverse" to keep docs links on the top of the mappings code block on smaller screen
<EuiFlexGroup
wrap
direction="rowReverse"
const { euiTheme } = useEuiTheme();
const [isJSONVisible, setIsJSONVisible] = useState(true);
const onToggleChange = () => {
setIsJSONVisible(!isJSONVisible);
};
const mappingsDefinition = extractMappingsDefinition(jsonData);
const { parsedDefaultValue } = useMemo<MappingsEditorParsedMetadata>(() => {
if (mappingsDefinition === null) {
return { multipleMappingsDeclared: true };
}
const {
_source,
_meta,
_routing,
_size,
dynamic,
properties,
runtime,
/* eslint-disable @typescript-eslint/naming-convention */
numeric_detection,
date_detection,
dynamic_date_formats,
dynamic_templates,
/* eslint-enable @typescript-eslint/naming-convention */
} = mappingsDefinition;
const parsed = {
configuration: {
_source,
_meta,
_routing,
_size,
dynamic,
numeric_detection,
date_detection,
dynamic_date_formats,
},
fields: properties,
templates: {
dynamic_templates,
},
runtime,
};
return { parsedDefaultValue: parsed, multipleMappingsDeclared: false };
}, [mappingsDefinition]);
useMappingsStateListener({ value: parsedDefaultValue, status: 'disabled' });
const {
fields: { byId, rootLevelFields },
search,
documentFields,
} = useMappingsState();
const getField = useCallback((fieldId: string) => byId[fieldId], [byId]);
const fields = useMemo(() => rootLevelFields.map(getField), [rootLevelFields, getField]);
const dispatch = useDispatch();
const onSearchChange = useCallback(
(value: string) => {
dispatch({ type: 'search:update', value });
},
[dispatch]
);
const searchTerm = search.term.trim();
const jsonBlock = (
<EuiCodeBlock
language="json"
isCopyable
data-test-subj="indexDetailsMappingsCodeBlock"
css={css`
height: 100%;
`}
>
<EuiFlexItem
grow={1}
css={css`
min-width: 400px;
`}
>
<EuiPanel grow={false} paddingSize="l">
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type="iInCircle" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h2>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.docsCardTitle"
defaultMessage="About index mappings"
/>
</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.docsCardDescription"
defaultMessage="Your documents are made up of a set of fields. Index mappings give each field a type
(such as keyword, number, or date) and additional subfields. These index mappings determine the functions
available in your relevance tuning and search experience."
/>
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiLink
data-test-subj="indexDetailsMappingsDocsLink"
href={documentationService.getMappingDocumentationLink()}
target="_blank"
external
>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.docsCardLink"
defaultMessage="Learn more about mappings"
/>
</EuiLink>
</EuiPanel>
{extensionsService.indexMappingsContent && (
<>
<EuiSpacer />
{extensionsService.indexMappingsContent.renderContent({ index, getUrlForApp })}
</>
)}
</EuiFlexItem>
{data}
</EuiCodeBlock>
);
<EuiFlexItem
grow={3}
css={css`
min-width: 600px;
`}
>
<EuiPanel>
<EuiCodeBlock
language="json"
isCopyable
data-test-subj="indexDetailsMappingsCodeBlock"
css={css`
height: 100%;
`}
>
{data}
</EuiCodeBlock>
</EuiPanel>
const treeViewBlock = (
<EuiFlexGroup direction="column">
<EuiFlexItem>
{mappingsDefinition === null ? (
<EuiEmptyPrompt
color="danger"
iconType="error"
title={
<h2>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.invalidMappingKeysErrorMessageTitle"
defaultMessage="Unable to load the mapping"
/>
</h2>
}
body={
<h2>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.invalidMappingKeysErrorMessageBody"
defaultMessage="The mapping contains invalid keys. Please provide a mapping with valid keys."
/>
</h2>
}
/>
) : searchTerm !== '' ? (
<SearchResult result={search.result} documentFieldsState={documentFields} />
) : (
<FieldsList fields={fields} />
)}
</EuiFlexItem>
</EuiFlexGroup>
);
return (
// using "rowReverse" to keep docs links on the top of the mappings code block on smaller screen
<>
<EuiFlexGroup style={{ marginBottom: euiTheme.size.l }}>
<DocumentFieldsSearch
searchValue={search.term}
onSearchChange={onSearchChange}
disabled={isJSONVisible}
/>
<EuiButton data-test-subj="indexDetailsMappingsToggleViewButton" onClick={onToggleChange}>
{isJSONVisible ? (
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.tableView"
defaultMessage="List"
/>
) : (
<FormattedMessage id="xpack.idxMgmt.indexDetails.mappings.json" defaultMessage="JSON" />
)}
</EuiButton>
</EuiFlexGroup>
<EuiFlexGroup
wrap
direction="rowReverse"
css={css`
height: 100%;
`}
>
<EuiFlexItem
grow={1}
css={css`
min-width: 400px;
`}
>
<EuiPanel grow={false} paddingSize="l">
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type="iInCircle" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="xs">
<h2>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.docsCardTitle"
defaultMessage="About index mappings"
/>
</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiText>
<p>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.docsCardDescription"
defaultMessage="Your documents are made up of a set of fields. Index mappings give each field a type
(such as keyword, number, or date) and additional subfields. These index mappings determine the functions
available in your relevance tuning and search experience."
/>
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiLink
data-test-subj="indexDetailsMappingsDocsLink"
href={documentationService.getMappingDocumentationLink()}
target="_blank"
external
>
<FormattedMessage
id="xpack.idxMgmt.indexDetails.mappings.docsCardLink"
defaultMessage="Learn more about mappings"
/>
</EuiLink>
</EuiPanel>
{extensionsService.indexMappingsContent && (
<>
<EuiSpacer />
{extensionsService.indexMappingsContent.renderContent({ index, getUrlForApp })}
</>
)}
</EuiFlexItem>
<EuiFlexItem
grow={3}
css={css`
min-width: 600px;
`}
>
<EuiPanel>{isJSONVisible ? jsonBlock : treeViewBlock}</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};