Skip to content

Commit

Permalink
Create a Table view for index mappings (elastic#178360)
Browse files Browse the repository at this point in the history
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




https://github.com/elastic/kibana/assets/132922331/7211e778-b33b-4b2c-93d8-6b9b7d65956e

---------

Co-authored-by: Elena Stoeva <[email protected]>
  • Loading branch information
saikatsarkar056 and ElenaStoeva authored Mar 18, 2024
1 parent ad299de commit 3a5136a
Show file tree
Hide file tree
Showing 9 changed files with 397 additions and 189 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>
);
});
Original file line number Diff line number Diff line change
@@ -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>
);
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { useEffect, useMemo } from 'react';

import {
DocumentFieldsStatus,
Field,
Mappings,
MappingsConfiguration,
Expand All @@ -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();

Expand All @@ -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)
Expand All @@ -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);

// Get the runtime fields
const runtime = deNormalizeRuntimeFields(state.runtimeFields);

const configurationData = state.configuration.data.format();
const templatesData = state.templates.data.format();

const output = {
...stripUndefinedValues({
...configurationData,
...templatesData,
}),
};

// Mapped fields
if (fields && Object.keys(fields).length > 0) {
output.properties = fields;
}

// 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);

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];

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,
});
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);

const configurationData = state.configuration.data.format();
const templatesData = state.templates.data.format();

const output = {
...stripUndefinedValues({
...configurationData,
...templatesData,
}),
};

// Mapped fields
if (fields && Object.keys(fields).length > 0) {
output.properties = fields;
}

// 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);

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];

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,
});
}
}, [state, onChange, dispatch]);

useEffect(() => {
Expand All @@ -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]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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} />;
};
Loading

0 comments on commit 3a5136a

Please sign in to comment.