Index pattern field editor (#88995)

Index pattern field editor
This commit is contained in:
Sébastien Loix 2021-02-18 18:00:43 +00:00 committed by GitHub
parent df8f2b1412
commit eddf1c94b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
173 changed files with 4358 additions and 1034 deletions

View file

@ -29,6 +29,7 @@
"maps_legacy": "src/plugins/maps_legacy",
"monaco": "packages/kbn-monaco/src",
"presentationUtil": "src/plugins/presentation_util",
"indexPatternFieldEditor": "src/plugins/index_pattern_field_editor",
"indexPatternManagement": "src/plugins/index_pattern_management",
"advancedSettings": "src/plugins/advanced_settings",
"kibana_legacy": "src/plugins/kibana_legacy",

View file

@ -93,6 +93,10 @@ for use in their own application.
|Moves the legacy ui/registry/feature_catalogue module for registering "features" that should be shown in the home page's feature catalogue to a service within a "home" plugin. The feature catalogue refered to here should not be confused with the "feature" plugin for registering features used to derive UI capabilities for feature controls.
|{kib-repo}blob/{branch}/src/plugins/index_pattern_field_editor/README.md[indexPatternFieldEditor]
|The reusable field editor across Kibana!
|{kib-repo}blob/{branch}/src/plugins/index_pattern_management[indexPatternManagement]
|WARNING: Missing README.

View file

@ -18,7 +18,12 @@ const toDiagnostics = (error: PainlessError): monaco.editor.IMarkerData => {
};
};
export interface SyntaxErrors {
[modelId: string]: PainlessError[];
}
export class DiagnosticsAdapter {
private errors: SyntaxErrors = {};
constructor(private worker: WorkerAccessor) {
const onModelAdd = (model: monaco.editor.IModel): void => {
let handle: any;
@ -55,8 +60,16 @@ export class DiagnosticsAdapter {
if (errorMarkers) {
const model = monaco.editor.getModel(resource);
this.errors = {
...this.errors,
[model!.id]: errorMarkers,
};
// Set the error markers and underline them with "Error" severity
monaco.editor.setModelMarkers(model!, ID, errorMarkers.map(toDiagnostics));
}
}
public getSyntaxErrors() {
return this.errors;
}
}

View file

@ -8,8 +8,14 @@
import { ID } from './constants';
import { lexerRules, languageConfiguration } from './lexer_rules';
import { getSuggestionProvider } from './language';
import { getSuggestionProvider, getSyntaxErrors } from './language';
export const PainlessLang = { ID, getSuggestionProvider, lexerRules, languageConfiguration };
export const PainlessLang = {
ID,
getSuggestionProvider,
lexerRules,
languageConfiguration,
getSyntaxErrors,
};
export { PainlessContext, PainlessAutocompleteField } from './types';

View file

@ -13,7 +13,7 @@ import { ID } from './constants';
import { PainlessContext, PainlessAutocompleteField } from './types';
import { PainlessWorker } from './worker';
import { PainlessCompletionAdapter } from './completion_adapter';
import { DiagnosticsAdapter } from './diagnostics_adapter';
import { DiagnosticsAdapter, SyntaxErrors } from './diagnostics_adapter';
const workerProxyService = new WorkerProxyService();
const editorStateService = new EditorStateService();
@ -33,8 +33,15 @@ export const getSuggestionProvider = (
return new PainlessCompletionAdapter(worker, editorStateService);
};
let diagnosticsAdapter: DiagnosticsAdapter;
// Returns syntax errors for all models by model id
export const getSyntaxErrors = (): SyntaxErrors => {
return diagnosticsAdapter.getSyntaxErrors();
};
monaco.languages.onLanguage(ID, async () => {
workerProxyService.setup();
new DiagnosticsAdapter(worker);
diagnosticsAdapter = new DiagnosticsAdapter(worker);
});

View file

@ -33,7 +33,7 @@ pageLoadAssetSize:
home: 41661
indexLifecycleManagement: 107090
indexManagement: 140608
indexPatternManagement: 154222
indexPatternManagement: 28222
infra: 204800
fleet: 415829
ingestPipelines: 58003
@ -103,6 +103,7 @@ pageLoadAssetSize:
stackAlerts: 29684
presentationUtil: 28545
spacesOss: 18817
indexPatternFieldEditor: 90489
osquery: 107090
fileUpload: 25664
banners: 17946

View file

@ -121,6 +121,7 @@ export class DocLinksService {
indexPatterns: {
loadingData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/tutorial-load-dataset.html`,
introduction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index-patterns.html`,
fieldFormattersString: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/field-formatters-string.html`,
},
addData: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/connect-to-elasticsearch.html`,
kibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index.html`,

View file

@ -0,0 +1,9 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const;

View file

@ -14,7 +14,7 @@ Object {
},
"count": 1,
"esTypes": Array [
"text",
"keyword",
],
"lang": "lang",
"name": "name",
@ -49,7 +49,7 @@ Object {
"count": 1,
"customLabel": undefined,
"esTypes": Array [
"text",
"keyword",
],
"format": Object {
"id": "number",

View file

@ -26,7 +26,7 @@ describe('Field', function () {
script: 'script',
lang: 'lang',
count: 1,
esTypes: ['text'],
esTypes: ['text'], // note, this will get replaced by the runtime field type
aggregatable: true,
filterable: true,
searchable: true,
@ -71,7 +71,7 @@ describe('Field', function () {
});
it('sets type field when _source field', () => {
const field = getField({ name: '_source' });
const field = getField({ name: '_source', runtimeField: undefined });
expect(field.type).toEqual('_source');
});

View file

@ -7,7 +7,7 @@
*/
import type { RuntimeField } from '../types';
import { KbnFieldType, getKbnFieldType } from '../../kbn_field_types';
import { KbnFieldType, getKbnFieldType, castEsToKbnFieldTypeName } from '../../kbn_field_types';
import { KBN_FIELD_TYPES } from '../../kbn_field_types/types';
import type { IFieldType } from './types';
import { FieldSpec, IndexPattern } from '../..';
@ -99,11 +99,13 @@ export class IndexPatternField implements IFieldType {
}
public get type() {
return this.spec.type;
return this.runtimeField?.type
? castEsToKbnFieldTypeName(this.runtimeField?.type)
: this.spec.type;
}
public get esTypes() {
return this.spec.esTypes;
return this.runtimeField?.type ? [this.runtimeField?.type] : this.spec.esTypes;
}
public get scripted() {

View file

@ -12,3 +12,4 @@ export { IndexPatternsService, IndexPatternsContract } from './index_patterns';
export type { IndexPattern } from './index_patterns';
export * from './errors';
export * from './expressions';
export * from './constants';

View file

@ -565,7 +565,9 @@ Object {
"conflictDescriptions": undefined,
"count": 0,
"customLabel": undefined,
"esTypes": undefined,
"esTypes": Array [
"keyword",
],
"format": Object {
"id": "number",
"params": Object {
@ -587,7 +589,7 @@ Object {
"searchable": false,
"shortDotsEnable": false,
"subType": undefined,
"type": undefined,
"type": "string",
},
"script date": Object {
"aggregatable": true,

View file

@ -389,6 +389,8 @@ export class IndexPattern implements IIndexPattern {
existingField.runtimeField = undefined;
} else {
// runtimeField only
this.setFieldCustomLabel(name, null);
this.deleteFieldFormat(name);
this.fields.remove(existingField);
}
}
@ -423,7 +425,6 @@ export class IndexPattern implements IIndexPattern {
if (fieldObject) {
fieldObject.customLabel = newCustomLabel;
return;
}
this.setFieldAttrs(fieldName, 'customLabel', newCustomLabel);

View file

@ -415,11 +415,10 @@ export class IndexPatternsService {
},
spec.fieldAttrs
);
// APPLY RUNTIME FIELDS
// CREATE RUNTIME FIELDS
for (const [key, value] of Object.entries(runtimeFieldMap || {})) {
if (spec.fields[key]) {
spec.fields[key].runtimeField = value;
} else {
// do not create runtime field if mapped field exists
if (!spec.fields[key]) {
spec.fields[key] = {
name: key,
type: castEsToKbnFieldTypeName(value.type),

View file

@ -10,15 +10,16 @@ import { ToastInputFields, ErrorToastOptions } from 'src/core/public/notificatio
// eslint-disable-next-line
import type { SavedObject } from 'src/core/server';
import { IFieldType } from './fields';
import { RUNTIME_FIELD_TYPES } from './constants';
import { SerializedFieldFormat } from '../../../expressions/common';
import { KBN_FIELD_TYPES, IndexPatternField, FieldFormat } from '..';
export type FieldFormatMap = Record<string, SerializedFieldFormat>;
const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const;
type RuntimeType = typeof RUNTIME_FIELD_TYPES[number];
export type RuntimeType = typeof RUNTIME_FIELD_TYPES[number];
export interface RuntimeField {
type: RuntimeType;
script: {
script?: {
source: string;
};
}

View file

@ -211,11 +211,12 @@ export function useForm<T extends FormData = FormData, I extends FormData = T>(
// ----------------------------------
const addField: FormHook<T, I>['__addField'] = useCallback(
(field) => {
const fieldExists = fieldsRefs.current[field.path] !== undefined;
fieldsRefs.current[field.path] = field;
updateFormDataAt(field.path, field.value);
if (!field.isValidated) {
if (!fieldExists && !field.isValidated) {
setIsValid(undefined);
// When we submit the form (and set "isSubmitted" to "true"), we validate **all fields**.

View file

@ -0,0 +1,69 @@
# Index pattern field editor
The reusable field editor across Kibana!
This editor can be used to
* create or edit a runtime field inside an index pattern.
* edit concrete (mapped) fields. In this case certain functionalities will be disabled like the possibility to change the field _type_ or to set the field _value_.
## How to use
You first need to add in your kibana.json the "`indexPatternFieldEditor`" plugin as a required dependency of your plugin.
You will then receive in the start contract of the indexPatternFieldEditor plugin the following API:
### `openEditor(options: OpenFieldEditorOptions): CloseEditor`
Use this method to open the index pattern field editor to either create (runtime) or edit (concrete | runtime) a field.
#### `options`
`ctx: FieldEditorContext` (**required**)
This is the only required option. You need to provide the context in which the editor is being consumed. This object has the following properties:
- `indexPattern: IndexPattern`: the index pattern you want to create/edit the field into.
`onSave(field: IndexPatternField): void` (optional)
You can provide an optional `onSave` handler to be notified when the field has being created/updated. This handler is called after the field has been persisted to the saved object.
`fieldName: string` (optional)
You can optionally pass the name of a field to edit. Leave empty to create a new runtime field based field.
### `userPermissions.editIndexPattern(): boolean`
Convenience method that uses the `core.application.capabilities` api to determine whether the user can edit the index pattern.
### `<DeleteRuntimeFieldProvider />`
This children func React component provides a handler to delete one or multiple runtime fields.
#### Props
* `indexPattern: IndexPattern`: the current index pattern. (**required**)
```js
const { DeleteRuntimeFieldProvider } = indexPatternFieldEditor;
// Single field
<DeleteRuntimeFieldProvider indexPattern={indexPattern}>
{(deleteField) => (
<EuiButton fill color="danger" onClick={() => deleteField('myField')}>
Delete
</EuiButton>
)}
</DeleteRuntimeFieldProvider>
// Multiple fields
<DeleteRuntimeFieldProvider indexPattern={indexPattern}>
{(deleteFields) => (
<EuiButton fill color="danger" onClick={() => deleteFields(['field1', 'field2', 'field3'])}>
Delete
</EuiButton>
)}
</DeleteRuntimeFieldProvider>
```

View file

@ -0,0 +1,13 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/src/plugins/index_pattern_field_editor'],
};

View file

@ -0,0 +1,9 @@
{
"id": "indexPatternFieldEditor",
"version": "kibana",
"server": false,
"ui": true,
"requiredPlugins": ["data"],
"optionalPlugins": ["usageCollection"],
"requiredBundles": ["kibanaReact", "esUiShared", "usageCollection"]
}

View file

@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2014 Steven Skelton
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,129 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
type DeleteFieldFunc = (fieldName: string | string[]) => void;
export interface Props {
children: (deleteFieldHandler: DeleteFieldFunc) => React.ReactNode;
onConfirmDelete: (fieldsToDelete: string[]) => Promise<void>;
}
interface State {
isModalOpen: boolean;
fieldsToDelete: string[];
}
const geti18nTexts = (fieldsToDelete?: string[]) => {
let modalTitle = '';
if (fieldsToDelete) {
const isSingle = fieldsToDelete.length === 1;
modalTitle = isSingle
? i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmModal.deleteSingleTitle',
{
defaultMessage: `Remove field '{name}'?`,
values: { name: fieldsToDelete[0] },
}
)
: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmModal.deleteMultipleTitle',
{
defaultMessage: `Remove {count} fields?`,
values: { count: fieldsToDelete.length },
}
);
}
return {
modalTitle,
confirmButtonText: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.removeButtonLabel',
{
defaultMessage: 'Remove',
}
),
cancelButtonText: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmationModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
),
warningMultipleFields: i18n.translate(
'indexPatternFieldEditor.deleteRuntimeField.confirmModal.multipleDeletionDescription',
{
defaultMessage: 'You are about to remove these runtime fields:',
}
),
};
};
export const DeleteRuntimeFieldProvider = ({ children, onConfirmDelete }: Props) => {
const [state, setState] = useState<State>({ isModalOpen: false, fieldsToDelete: [] });
const { isModalOpen, fieldsToDelete } = state;
const i18nTexts = geti18nTexts(fieldsToDelete);
const { modalTitle, confirmButtonText, cancelButtonText, warningMultipleFields } = i18nTexts;
const isMultiple = Boolean(fieldsToDelete.length > 1);
const deleteField: DeleteFieldFunc = useCallback((fieldNames) => {
setState({
isModalOpen: true,
fieldsToDelete: Array.isArray(fieldNames) ? fieldNames : [fieldNames],
});
}, []);
const closeModal = useCallback(() => {
setState({ isModalOpen: false, fieldsToDelete: [] });
}, []);
const confirmDelete = useCallback(async () => {
try {
await onConfirmDelete(fieldsToDelete);
closeModal();
} catch (e) {
// silently fail as "onConfirmDelete" is responsible
// to show a toast message if there is an error
}
}, [closeModal, onConfirmDelete, fieldsToDelete]);
return (
<>
{children(deleteField)}
{isModalOpen && (
<EuiOverlayMask>
<EuiConfirmModal
title={modalTitle}
data-test-subj="runtimeFieldDeleteConfirmModal"
onCancel={closeModal}
onConfirm={confirmDelete}
cancelButtonText={cancelButtonText}
buttonColor="danger"
confirmButtonText={confirmButtonText}
>
{isMultiple && (
<>
<p>{warningMultipleFields}</p>
<ul>
{fieldsToDelete.map((fieldName) => (
<li key={fieldName}>{fieldName}</li>
))}
</ul>
</>
)}
</EuiConfirmModal>
</EuiOverlayMask>
)}
</>
);
};

View file

@ -0,0 +1,62 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { NotificationsStart } from 'src/core/public';
import { IndexPattern, UsageCollectionStart } from '../../shared_imports';
import { pluginName } from '../../constants';
import { DeleteRuntimeFieldProvider, Props as DeleteProviderProps } from './delete_field_provider';
import { DataPublicPluginStart } from '../../../../data/public';
export interface Props extends Omit<DeleteProviderProps, 'onConfirmDelete'> {
indexPattern: IndexPattern;
onDelete?: (fieldNames: string[]) => void;
}
export const getDeleteProvider = (
indexPatternService: DataPublicPluginStart['indexPatterns'],
usageCollection: UsageCollectionStart,
notifications: NotificationsStart
): React.FunctionComponent<Props> => {
return React.memo(({ indexPattern, children, onDelete }: Props) => {
const deleteFields = useCallback(
async (fieldNames: string[]) => {
fieldNames.forEach((fieldName) => {
indexPattern.removeRuntimeField(fieldName);
});
try {
usageCollection.reportUiCounter(
pluginName,
usageCollection.METRIC_TYPE.COUNT,
'delete_runtime'
);
// eslint-disable-next-line no-empty
} catch {}
try {
await indexPatternService.updateSavedObject(indexPattern);
} catch (e) {
const title = i18n.translate('indexPatternFieldEditor.save.deleteErrorTitle', {
defaultMessage: 'Failed to save field removal',
});
notifications.toasts.addError(e, { title });
}
if (onDelete) {
onDelete(fieldNames);
}
},
[onDelete, indexPattern]
);
return <DeleteRuntimeFieldProvider children={children} onConfirmDelete={deleteFields} />;
});
};

View file

@ -0,0 +1,9 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { getDeleteProvider, Props as DeleteProviderProps } from './get_delete_provider';

View file

@ -0,0 +1,44 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty, EuiSpacer } from '@elastic/eui';
interface Props {
children: React.ReactNode;
}
export const AdvancedParametersSection = ({ children }: Props) => {
const [isVisible, setIsVisible] = useState<boolean>(false);
const toggleIsVisible = () => {
setIsVisible(!isVisible);
};
return (
<>
<EuiButtonEmpty onClick={toggleIsVisible} flush="left" data-test-subj="toggleAdvancedSetting">
{isVisible
? i18n.translate('indexPatternFieldEditor.editor.form.advancedSettings.hideButtonLabel', {
defaultMessage: 'Hide advanced settings',
})
: i18n.translate('indexPatternFieldEditor.editor.form.advancedSettings.showButtonLabel', {
defaultMessage: 'Show advanced settings',
})}
</EuiButtonEmpty>
<div style={{ display: isVisible ? 'block' : 'none' }} data-test-subj="advancedSettings">
<EuiSpacer size="m" />
{/* We ned to wrap the children inside a "div" to have our css :first-child rule */}
<div>{children}</div>
</div>
</>
);
};

View file

@ -0,0 +1,37 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { RuntimeType } from '../../shared_imports';
export const RUNTIME_FIELD_OPTIONS: Array<EuiComboBoxOptionOption<RuntimeType>> = [
{
label: 'Keyword',
value: 'keyword',
},
{
label: 'Long',
value: 'long',
},
{
label: 'Double',
value: 'double',
},
{
label: 'Date',
value: 'date',
},
{
label: 'IP',
value: 'ip',
},
{
label: 'Boolean',
value: 'boolean',
},
];

View file

@ -0,0 +1,212 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { act } from 'react-dom/test-utils';
import '../../test_utils/setup_environment';
import { registerTestBed, TestBed, getCommonActions } from '../../test_utils';
import { Field } from '../../types';
import { FieldEditor, Props, FieldEditorFormState } from './field_editor';
const defaultProps: Props = {
onChange: jest.fn(),
links: {
runtimePainless: 'https://elastic.co',
},
ctx: {
existingConcreteFields: [],
namesNotAllowed: [],
fieldTypeToProcess: 'runtime',
},
indexPattern: { fields: [] } as any,
fieldFormatEditors: {
getAll: () => [],
getById: () => undefined,
},
fieldFormats: {} as any,
uiSettings: {} as any,
syntaxError: {
error: null,
clear: () => {},
},
};
const setup = (props?: Partial<Props>) => {
const testBed = registerTestBed(FieldEditor, {
memoryRouter: {
wrapComponent: false,
},
})({ ...defaultProps, ...props }) as TestBed;
const actions = {
...getCommonActions(testBed),
};
return {
...testBed,
actions,
};
};
describe('<FieldEditor />', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
let testBed: TestBed & { actions: ReturnType<typeof getCommonActions> };
let onChange: jest.Mock<Props['onChange']> = jest.fn();
const lastOnChangeCall = (): FieldEditorFormState[] =>
onChange.mock.calls[onChange.mock.calls.length - 1];
const getLastStateUpdate = () => lastOnChangeCall()[0];
const submitFormAndGetData = async (state: FieldEditorFormState) => {
let formState:
| {
data: Field;
isValid: boolean;
}
| undefined;
let promise: ReturnType<FieldEditorFormState['submit']>;
await act(async () => {
// We can't await for the promise here as the validation for the
// "script" field has a setTimeout which is mocked by jest. If we await
// we don't have the chance to call jest.advanceTimersByTime and thus the
// test times out.
promise = state.submit();
});
await act(async () => {
// The painless syntax validation has a timeout set to 600ms
// we give it a bit more time just to be on the safe side
jest.advanceTimersByTime(1000);
});
await act(async () => {
promise.then((response) => {
formState = response;
});
});
return formState!;
};
beforeEach(() => {
onChange = jest.fn();
});
test('initial state should have "set custom label", "set value" and "set format" turned off', () => {
testBed = setup();
['customLabel', 'value', 'format'].forEach((row) => {
const testSubj = `${row}Row.toggle`;
const toggle = testBed.find(testSubj);
const isOn = toggle.props()['aria-checked'];
try {
expect(isOn).toBe(false);
} catch (e) {
e.message = `"${row}" row toggle expected to be 'off' but was 'on'. \n${e.message}`;
throw e;
}
});
});
test('should accept a defaultValue and onChange prop to forward the form state', async () => {
const field = {
name: 'foo',
type: 'date',
script: { source: 'emit("hello")' },
};
testBed = setup({ onChange, field });
expect(onChange).toHaveBeenCalled();
let lastState = getLastStateUpdate();
expect(lastState.isValid).toBe(undefined);
expect(lastState.isSubmitted).toBe(false);
expect(lastState.submit).toBeDefined();
const { data: formData } = await submitFormAndGetData(lastState);
expect(formData).toEqual(field);
// Make sure that both isValid and isSubmitted state are now "true"
lastState = getLastStateUpdate();
expect(lastState.isValid).toBe(true);
expect(lastState.isSubmitted).toBe(true);
});
describe('validation', () => {
test('should accept an optional list of existing fields and prevent creating duplicates', async () => {
const existingFields = ['myRuntimeField'];
testBed = setup({
onChange,
ctx: {
namesNotAllowed: existingFields,
existingConcreteFields: [],
fieldTypeToProcess: 'runtime',
},
});
const { form, component, actions } = testBed;
await act(async () => {
actions.toggleFormRow('value');
});
await act(async () => {
form.setInputValue('nameField.input', existingFields[0]);
form.setInputValue('scriptField', 'echo("hello")');
});
await act(async () => {
jest.advanceTimersByTime(1000); // Make sure our debounced error message is in the DOM
});
const lastState = getLastStateUpdate();
await submitFormAndGetData(lastState);
component.update();
expect(getLastStateUpdate().isValid).toBe(false);
expect(form.getErrorsMessages()).toEqual(['A field with this name already exists.']);
});
test('should not count the default value as a duplicate', async () => {
const existingRuntimeFieldNames = ['myRuntimeField'];
const field: Field = {
name: 'myRuntimeField',
type: 'boolean',
script: { source: 'emit("hello"' },
};
testBed = setup({
field,
onChange,
ctx: {
namesNotAllowed: existingRuntimeFieldNames,
existingConcreteFields: [],
fieldTypeToProcess: 'runtime',
},
});
const { form, component } = testBed;
const lastState = getLastStateUpdate();
await submitFormAndGetData(lastState);
component.update();
expect(getLastStateUpdate().isValid).toBe(true);
expect(form.getErrorsMessages()).toEqual([]);
});
});
});

View file

@ -0,0 +1,286 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiComboBoxOptionOption,
EuiCode,
} from '@elastic/eui';
import type { CoreStart } from 'src/core/public';
import {
Form,
useForm,
FormHook,
UseField,
TextField,
RuntimeType,
IndexPattern,
DataPublicPluginStart,
} from '../../shared_imports';
import { Field, InternalFieldType, PluginStart } from '../../types';
import { RUNTIME_FIELD_OPTIONS } from './constants';
import { schema } from './form_schema';
import { getNameFieldConfig } from './lib';
import {
TypeField,
CustomLabelField,
ScriptField,
FormatField,
PopularityField,
ScriptSyntaxError,
} from './form_fields';
import { FormRow } from './form_row';
import { AdvancedParametersSection } from './advanced_parameters_section';
export interface FieldEditorFormState {
isValid: boolean | undefined;
isSubmitted: boolean;
submit: FormHook<Field>['submit'];
}
export interface FieldFormInternal extends Omit<Field, 'type' | 'internalType'> {
type: Array<EuiComboBoxOptionOption<RuntimeType>>;
__meta__: {
isCustomLabelVisible: boolean;
isValueVisible: boolean;
isFormatVisible: boolean;
isPopularityVisible: boolean;
};
}
export interface Props {
/** Link URLs to our doc site */
links: {
runtimePainless: string;
};
/** Optional field to edit */
field?: Field;
/** Handler to receive state changes updates */
onChange?: (state: FieldEditorFormState) => void;
indexPattern: IndexPattern;
fieldFormatEditors: PluginStart['fieldFormatEditors'];
fieldFormats: DataPublicPluginStart['fieldFormats'];
uiSettings: CoreStart['uiSettings'];
/** Context object */
ctx: {
/** The internal field type we are dealing with (concrete|runtime)*/
fieldTypeToProcess: InternalFieldType;
/**
* An array of field names not allowed.
* e.g we probably don't want a user to give a name of an existing
* runtime field (for that the user should edit the existing runtime field).
*/
namesNotAllowed: string[];
/**
* An array of existing concrete fields. If the user gives a name to the runtime
* field that matches one of the concrete fields, a callout will be displayed
* to indicate that this runtime field will shadow the concrete field.
* It is also used to provide the list of field autocomplete suggestions to the code editor.
*/
existingConcreteFields: Array<{ name: string; type: string }>;
};
syntaxError: ScriptSyntaxError;
}
const geti18nTexts = (): {
[key: string]: { title: string; description: JSX.Element | string };
} => ({
customLabel: {
title: i18n.translate('indexPatternFieldEditor.editor.form.customLabelTitle', {
defaultMessage: 'Set custom label',
}),
description: i18n.translate('indexPatternFieldEditor.editor.form.customLabelDescription', {
defaultMessage: `Create a label to display in place of the field name in Discover, Maps, and Visualize. Useful for shortening a long field name. Queries and filters use the original field name.`,
}),
},
value: {
title: i18n.translate('indexPatternFieldEditor.editor.form.valueTitle', {
defaultMessage: 'Set value',
}),
description: (
<FormattedMessage
id="indexPatternFieldEditor.editor.form.valueDescription"
defaultMessage="Set a value for the field instead of retrieving it from the field with the same name in {source}."
values={{
source: <EuiCode>{'_source'}</EuiCode>,
}}
/>
),
},
format: {
title: i18n.translate('indexPatternFieldEditor.editor.form.formatTitle', {
defaultMessage: 'Set format',
}),
description: i18n.translate('indexPatternFieldEditor.editor.form.formatDescription', {
defaultMessage: `Set your preferred format for displaying the value. Changing the format can affect the value and prevent highlighting in Discover.`,
}),
},
popularity: {
title: i18n.translate('indexPatternFieldEditor.editor.form.popularityTitle', {
defaultMessage: 'Set popularity',
}),
description: i18n.translate('indexPatternFieldEditor.editor.form.popularityDescription', {
defaultMessage: `Adjust the popularity to make the field appear higher or lower in the fields list. By default, Discover orders fields from most selected to least selected.`,
}),
},
});
const formDeserializer = (field: Field): FieldFormInternal => {
let fieldType: Array<EuiComboBoxOptionOption<RuntimeType>>;
if (!field.type) {
fieldType = [RUNTIME_FIELD_OPTIONS[0]];
} else {
const label = RUNTIME_FIELD_OPTIONS.find(({ value }) => value === field.type)?.label;
fieldType = [{ label: label ?? field.type, value: field.type as RuntimeType }];
}
return {
...field,
type: fieldType,
__meta__: {
isCustomLabelVisible: field.customLabel !== undefined,
isValueVisible: field.script !== undefined,
isFormatVisible: field.format !== undefined,
isPopularityVisible: field.popularity !== undefined,
},
};
};
const formSerializer = (field: FieldFormInternal): Field => {
const { __meta__, type, ...rest } = field;
return {
type: type[0].value!,
...rest,
};
};
const FieldEditorComponent = ({
field,
onChange,
links,
indexPattern,
fieldFormatEditors,
fieldFormats,
uiSettings,
syntaxError,
ctx: { fieldTypeToProcess, namesNotAllowed, existingConcreteFields },
}: Props) => {
const { form } = useForm<Field, FieldFormInternal>({
defaultValue: field,
schema,
deserializer: formDeserializer,
serializer: formSerializer,
});
const { submit, isValid: isFormValid, isSubmitted } = form;
const nameFieldConfig = getNameFieldConfig(namesNotAllowed, field);
const i18nTexts = geti18nTexts();
useEffect(() => {
if (onChange) {
onChange({ isValid: isFormValid, isSubmitted, submit });
}
}, [onChange, isFormValid, isSubmitted, submit]);
return (
<Form form={form} className="indexPatternFieldEditor__form">
<EuiFlexGroup>
{/* Name */}
<EuiFlexItem>
<UseField<string, Field>
path="name"
config={nameFieldConfig}
component={TextField}
data-test-subj="nameField"
componentProps={{
euiFieldProps: {
disabled: fieldTypeToProcess === 'concrete',
'aria-label': i18n.translate('indexPatternFieldEditor.editor.form.nameAriaLabel', {
defaultMessage: 'Name field',
}),
},
}}
/>
</EuiFlexItem>
{/* Type */}
<EuiFlexItem>
<TypeField isDisabled={fieldTypeToProcess === 'concrete'} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xl" />
{/* Set custom label */}
<FormRow
title={i18nTexts.customLabel.title}
description={i18nTexts.customLabel.description}
formFieldPath="__meta__.isCustomLabelVisible"
data-test-subj="customLabelRow"
withDividerRule
>
<CustomLabelField />
</FormRow>
{/* Set value */}
{fieldTypeToProcess === 'runtime' && (
<FormRow
title={i18nTexts.value.title}
description={i18nTexts.value.description}
formFieldPath="__meta__.isValueVisible"
data-test-subj="valueRow"
withDividerRule
>
<ScriptField
existingConcreteFields={existingConcreteFields}
links={links}
syntaxError={syntaxError}
/>
</FormRow>
)}
{/* Set custom format */}
<FormRow
title={i18nTexts.format.title}
description={i18nTexts.format.description}
formFieldPath="__meta__.isFormatVisible"
data-test-subj="formatRow"
withDividerRule
>
<FormatField
indexPattern={indexPattern}
fieldFormatEditors={fieldFormatEditors}
fieldFormats={fieldFormats}
uiSettings={uiSettings}
/>
</FormRow>
{/* Advanced settings */}
<AdvancedParametersSection>
<FormRow
title={i18nTexts.popularity.title}
description={i18nTexts.popularity.description}
formFieldPath="__meta__.isPopularityVisible"
data-test-subj="popularityRow"
withDividerRule
>
<PopularityField />
</FormRow>
</AdvancedParametersSection>
</Form>
);
};
export const FieldEditor = React.memo(FieldEditorComponent);

View file

@ -0,0 +1,15 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { UseField, TextField } from '../../../shared_imports';
export const CustomLabelField = () => {
return <UseField path="customLabel" component={TextField} />;
};

View file

@ -0,0 +1,82 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, useEffect, useRef } from 'react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { UseField, useFormData, ES_FIELD_TYPES, useFormContext } from '../../../shared_imports';
import { FormatSelectEditor, FormatSelectEditorProps } from '../../field_format_editor';
import { FieldFormInternal } from '../field_editor';
import { FieldFormatConfig } from '../../../types';
export const FormatField = ({
indexPattern,
fieldFormatEditors,
fieldFormats,
uiSettings,
}: Omit<FormatSelectEditorProps, 'onChange' | 'onError' | 'esTypes'>) => {
const isMounted = useRef(false);
const [{ type }] = useFormData<FieldFormInternal>({ watch: ['name', 'type'] });
const { getFields, isSubmitted } = useFormContext();
const [formatError, setFormatError] = useState<string | undefined>();
// convert from combobox type to values
const typeValue = type.reduce((collector, item) => {
if (item.value !== undefined) {
collector.push(item.value as ES_FIELD_TYPES);
}
return collector;
}, [] as ES_FIELD_TYPES[]);
useEffect(() => {
if (formatError === undefined) {
getFields().format.setErrors([]);
} else {
getFields().format.setErrors([{ message: formatError }]);
}
}, [formatError, getFields]);
useEffect(() => {
if (isMounted.current) {
getFields().format.reset();
}
isMounted.current = true;
}, [type, getFields]);
return (
<UseField<FieldFormatConfig | undefined> path="format">
{({ setValue, errors, value }) => {
return (
<>
{isSubmitted && errors.length > 0 && (
<>
<EuiCallOut
title={errors.map((err) => err.message)}
color="danger"
iconType="cross"
data-test-subj="formFormatError"
/>
<EuiSpacer size="m" />
</>
)}
<FormatSelectEditor
esTypes={typeValue || (['keyword'] as ES_FIELD_TYPES[])}
indexPattern={indexPattern}
fieldFormatEditors={fieldFormatEditors}
fieldFormats={fieldFormats}
uiSettings={uiSettings}
onChange={setValue}
onError={setFormatError}
value={value}
key={typeValue.join(', ')}
/>
</>
);
}}
</UseField>
);
};

View file

@ -0,0 +1,17 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { TypeField } from './type_field';
export { CustomLabelField } from './custom_label_field';
export { PopularityField } from './popularity_field';
export { ScriptField, ScriptSyntaxError } from './script_field';
export { FormatField } from './format_field';

View file

@ -0,0 +1,21 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { UseField, NumericField } from '../../../shared_imports';
export const PopularityField = () => {
return (
<UseField
path="popularity"
component={NumericField}
componentProps={{ euiFieldProps: { 'data-test-subj': 'editorFieldCount' } }}
/>
);
};

View file

@ -0,0 +1,227 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFormRow, EuiLink, EuiCode, EuiCodeBlock, EuiSpacer, EuiTitle } from '@elastic/eui';
import { PainlessLang, PainlessContext } from '@kbn/monaco';
import {
UseField,
useFormData,
RuntimeType,
FieldConfig,
CodeEditor,
} from '../../../shared_imports';
import { RuntimeFieldPainlessError } from '../../../lib';
import { schema } from '../form_schema';
import type { FieldFormInternal } from '../field_editor';
interface Props {
links: { runtimePainless: string };
existingConcreteFields?: Array<{ name: string; type: string }>;
syntaxError: ScriptSyntaxError;
}
export interface ScriptSyntaxError {
error: RuntimeFieldPainlessError | null;
clear: () => void;
}
const mapReturnTypeToPainlessContext = (runtimeType: RuntimeType): PainlessContext => {
switch (runtimeType) {
case 'keyword':
return 'string_script_field_script_field';
case 'long':
return 'long_script_field_script_field';
case 'double':
return 'double_script_field_script_field';
case 'date':
return 'date_script_field';
case 'ip':
return 'ip_script_field_script_field';
case 'boolean':
return 'boolean_script_field_script_field';
default:
return 'string_script_field_script_field';
}
};
export const ScriptField = React.memo(({ existingConcreteFields, links, syntaxError }: Props) => {
const editorValidationTimeout = useRef<ReturnType<typeof setTimeout>>();
const [painlessContext, setPainlessContext] = useState<PainlessContext>(
mapReturnTypeToPainlessContext(schema.type.defaultValue[0].value!)
);
const [editorId, setEditorId] = useState<string | undefined>();
const suggestionProvider = PainlessLang.getSuggestionProvider(
painlessContext,
existingConcreteFields
);
const [{ type, script: { source } = { source: '' } }] = useFormData<FieldFormInternal>({
watch: ['type', 'script.source'],
});
const { clear: clearSyntaxError } = syntaxError;
const sourceFieldConfig: FieldConfig<string> = useMemo(() => {
return {
...schema.script.source,
validations: [
...schema.script.source.validations,
{
validator: () => {
if (editorValidationTimeout.current) {
clearTimeout(editorValidationTimeout.current);
}
return new Promise((resolve) => {
// monaco waits 500ms before validating, so we also add a delay
// before checking if there are any syntax errors
editorValidationTimeout.current = setTimeout(() => {
const painlessSyntaxErrors = PainlessLang.getSyntaxErrors();
// It is possible for there to be more than one editor in a view,
// so we need to get the syntax errors based on the editor (aka model) ID
const editorHasSyntaxErrors = editorId && painlessSyntaxErrors[editorId].length > 0;
if (editorHasSyntaxErrors) {
return resolve({
message: i18n.translate(
'indexPatternFieldEditor.editor.form.scriptEditorValidationMessage',
{
defaultMessage: 'Invalid Painless syntax.',
}
),
});
}
resolve(undefined);
}, 600);
});
},
},
],
};
}, [editorId]);
useEffect(() => {
setPainlessContext(mapReturnTypeToPainlessContext(type[0]!.value!));
}, [type]);
useEffect(() => {
// Whenever the source changes we clear potential syntax errors
clearSyntaxError();
}, [source, clearSyntaxError]);
return (
<UseField<string> path="script.source" config={sourceFieldConfig}>
{({ value, setValue, label, isValid, getErrorsMessages }) => {
let errorMessage: string | null = '';
if (syntaxError.error !== null) {
errorMessage = syntaxError.error.reason ?? syntaxError.error.message;
} else {
errorMessage = getErrorsMessages();
}
return (
<>
<EuiFormRow
label={label}
error={errorMessage}
isInvalid={syntaxError.error !== null || !isValid}
helpText={
<FormattedMessage
id="indexPatternFieldEditor.editor.form.source.scriptFieldHelpText"
defaultMessage="Runtime fields without a script retrieve values from {source}. If the field doesn't exist in _source, a search request returns no value. {learnMoreLink}"
values={{
learnMoreLink: (
<EuiLink
href={links.runtimePainless}
target="_blank"
external
data-test-subj="painlessSyntaxLearnMoreLink"
>
{i18n.translate(
'indexPatternFieldEditor.editor.form.script.learnMoreLinkText',
{
defaultMessage: 'Learn about script syntax.',
}
)}
</EuiLink>
),
source: <EuiCode>{'_source'}</EuiCode>,
}}
/>
}
fullWidth
>
<CodeEditor
languageId={PainlessLang.ID}
suggestionProvider={suggestionProvider}
// 99% width allows the editor to resize horizontally. 100% prevents it from resizing.
width="99%"
height="300px"
value={value}
onChange={setValue}
editorDidMount={(editor) => setEditorId(editor.getModel()?.id)}
options={{
fontSize: 12,
minimap: {
enabled: false,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
wrappingIndent: 'indent',
automaticLayout: true,
suggest: {
snippetsPreventQuickSuggestions: false,
},
}}
data-test-subj="scriptField"
aria-label={i18n.translate(
'indexPatternFieldEditor.editor.form.scriptEditorAriaLabel',
{
defaultMessage: 'Script editor',
}
)}
/>
</EuiFormRow>
{/* Help the user debug the error by showing where it failed in the script */}
{syntaxError.error !== null && (
<>
<EuiSpacer />
<EuiTitle size="xs">
<h3>
{i18n.translate(
'indexPatternFieldEditor.editor.form.scriptEditor.debugErrorMessage',
{
defaultMessage: 'Syntax error detail',
}
)}
</h3>
</EuiTitle>
<EuiSpacer size="xs" />
<EuiCodeBlock
// @ts-ignore
whiteSpace="pre"
>
{syntaxError.error.scriptStack.join('\n')}
</EuiCodeBlock>
</>
)}
</>
);
}}
</UseField>
);
});

View file

@ -0,0 +1,65 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { UseField, RuntimeType } from '../../../shared_imports';
import { RUNTIME_FIELD_OPTIONS } from '../constants';
interface Props {
isDisabled?: boolean;
}
export const TypeField = ({ isDisabled = false }: Props) => {
return (
<UseField<Array<EuiComboBoxOptionOption<RuntimeType>>> path="type">
{({ label, value, setValue }) => {
if (value === undefined) {
return null;
}
return (
<>
<EuiFormRow label={label} fullWidth>
<EuiComboBox
placeholder={i18n.translate(
'indexPatternFieldEditor.editor.form.runtimeType.placeholderLabel',
{
defaultMessage: 'Select a type',
}
)}
singleSelection={{ asPlainText: true }}
options={RUNTIME_FIELD_OPTIONS}
selectedOptions={value}
onChange={(newValue) => {
if (newValue.length === 0) {
// Don't allow clearing the type. One must always be selected
return;
}
setValue(newValue);
}}
isClearable={false}
isDisabled={isDisabled}
data-test-subj="typeField"
aria-label={i18n.translate(
'indexPatternFieldEditor.editor.form.typeSelectAriaLabel',
{
defaultMessage: 'Type select',
}
)}
fullWidth
/>
</EuiFormRow>
</>
);
}}
</UseField>
);
};

View file

@ -0,0 +1,86 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { get } from 'lodash';
import {
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiText,
EuiHorizontalRule,
EuiSpacer,
} from '@elastic/eui';
import { UseField, ToggleField, useFormData } from '../../shared_imports';
interface Props {
title: string;
formFieldPath: string;
children: React.ReactNode;
description?: string | JSX.Element;
withDividerRule?: boolean;
'data-test-subj'?: string;
}
export const FormRow = ({
title,
description,
children,
formFieldPath,
withDividerRule = false,
'data-test-subj': dataTestSubj,
}: Props) => {
const [formData] = useFormData({ watch: formFieldPath });
const isContentVisible = Boolean(get(formData, formFieldPath));
return (
<>
<EuiFlexGroup data-test-subj={dataTestSubj ?? 'formRow'}>
<EuiFlexItem grow={false}>
<UseField
path={formFieldPath}
component={ToggleField}
componentProps={{
euiFieldProps: {
label: title,
showLabel: false,
'data-test-subj': 'toggle',
},
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<div>
{/* Title */}
<EuiTitle size="xs">
<h3>{title}</h3>
</EuiTitle>
<EuiSpacer size="xs" />
{/* Description */}
<EuiText size="s" color="subdued">
{description}
</EuiText>
{/* Content */}
{isContentVisible && (
<>
<EuiSpacer size="l" />
{children}
</>
)}
</div>
</EuiFlexItem>
</EuiFlexGroup>
{withDividerRule && <EuiHorizontalRule />}
</>
);
};

View file

@ -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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { fieldValidators } from '../../shared_imports';
import { RUNTIME_FIELD_OPTIONS } from './constants';
const { emptyField, numberGreaterThanField } = fieldValidators;
export const schema = {
name: {
label: i18n.translate('indexPatternFieldEditor.editor.form.nameLabel', {
defaultMessage: 'Name',
}),
validations: [
{
validator: emptyField(
i18n.translate(
'indexPatternFieldEditor.editor.form.validations.nameIsRequiredErrorMessage',
{
defaultMessage: 'A name is required.',
}
)
),
},
],
},
type: {
label: i18n.translate('indexPatternFieldEditor.editor.form.runtimeTypeLabel', {
defaultMessage: 'Type',
}),
defaultValue: [RUNTIME_FIELD_OPTIONS[0]],
},
script: {
source: {
label: i18n.translate('indexPatternFieldEditor.editor.form.defineFieldLabel', {
defaultMessage: 'Define script',
}),
validations: [
{
validator: emptyField(
i18n.translate(
'indexPatternFieldEditor.editor.form.validations.scriptIsRequiredErrorMessage',
{
defaultMessage: 'A script is required to set the field value.',
}
)
),
},
],
},
},
customLabel: {
label: i18n.translate('indexPatternFieldEditor.editor.form.customLabelLabel', {
defaultMessage: 'Custom label',
}),
validations: [
{
validator: emptyField(
i18n.translate(
'indexPatternFieldEditor.editor.form.validations.customLabelIsRequiredErrorMessage',
{
defaultMessage: 'Give a label to the field.',
}
)
),
},
],
},
popularity: {
label: i18n.translate('indexPatternFieldEditor.editor.form.popularityLabel', {
defaultMessage: 'Popularity',
}),
validations: [
{
validator: emptyField(
i18n.translate(
'indexPatternFieldEditor.editor.form.validations.popularityIsRequiredErrorMessage',
{
defaultMessage: 'Give a popularity to the field.',
}
)
),
},
{
validator: numberGreaterThanField({
than: 0,
allowEquality: true,
message: i18n.translate(
'indexPatternFieldEditor.editor.form.validations.popularityGreaterThan0ErrorMessage',
{
defaultMessage: 'The popularity must be zero or greater.',
}
),
}),
},
],
},
__meta__: {
isCustomLabelVisible: {
defaultValue: false,
},
isValueVisible: {
defaultValue: false,
},
isFormatVisible: {
defaultValue: false,
},
isPopularityVisible: {
defaultValue: false,
},
},
};

View file

@ -0,0 +1,9 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { FieldEditor } from './field_editor';

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { ValidationFunc, FieldConfig } from '../../shared_imports';
import { Field } from '../../types';
import { schema } from './form_schema';
import { Props } from './field_editor';
const createNameNotAllowedValidator = (
namesNotAllowed: string[]
): ValidationFunc<{}, string, string> => ({ value }) => {
if (namesNotAllowed.includes(value)) {
return {
message: i18n.translate(
'indexPatternFieldEditor.editor.runtimeFieldsEditor.existRuntimeFieldNamesValidationErrorMessage',
{
defaultMessage: 'A field with this name already exists.',
}
),
};
}
};
/**
* Dynamically retrieve the config for the "name" field, adding
* a validator to avoid duplicated runtime fields to be created.
*
* @param namesNotAllowed Array of names not allowed for the field "name"
* @param field Initial value of the form
*/
export const getNameFieldConfig = (
namesNotAllowed?: string[],
field?: Props['field']
): FieldConfig<string, Field> => {
const nameFieldConfig = schema.name as FieldConfig<string, Field>;
if (!namesNotAllowed) {
return nameFieldConfig;
}
// Add validation to not allow duplicates
return {
...nameFieldConfig!,
validations: [
...(nameFieldConfig.validations ?? []),
{
validator: createNameNotAllowedValidator(
namesNotAllowed.filter((name) => name !== field?.name)
),
},
],
};
};

View file

@ -0,0 +1,32 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiCallOut } from '@elastic/eui';
export const ShadowingFieldWarning = () => {
return (
<EuiCallOut
title={i18n.translate('indexPatternFieldEditor.editor.form.fieldShadowingCalloutTitle', {
defaultMessage: 'Field shadowing',
})}
color="warning"
iconType="pin"
size="s"
data-test-subj="shadowingFieldCallout"
>
<div>
{i18n.translate('indexPatternFieldEditor.editor.form.fieldShadowingCalloutDescription', {
defaultMessage:
'This field shares the name of a mapped field. Values for this field will be returned in search results.',
})}
</div>
</EuiCallOut>
);
};

View file

@ -0,0 +1,198 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { act } from 'react-dom/test-utils';
import '../test_utils/setup_environment';
import { registerTestBed, TestBed, noop, docLinks, getCommonActions } from '../test_utils';
import { FieldEditor } from './field_editor';
import { FieldEditorFlyoutContent, Props } from './field_editor_flyout_content';
const defaultProps: Props = {
onSave: noop,
onCancel: noop,
docLinks,
FieldEditor,
indexPattern: { fields: [] } as any,
uiSettings: {} as any,
fieldFormats: {} as any,
fieldFormatEditors: {} as any,
fieldTypeToProcess: 'runtime',
runtimeFieldValidator: () => Promise.resolve(null),
isSavingField: false,
};
const setup = (props: Props = defaultProps) => {
const testBed = registerTestBed(FieldEditorFlyoutContent, {
memoryRouter: { wrapComponent: false },
})(props) as TestBed;
const actions = {
...getCommonActions(testBed),
};
return {
...testBed,
actions,
};
};
describe('<FieldEditorFlyoutContent />', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
test('should have the correct title', () => {
const { exists, find } = setup();
expect(exists('flyoutTitle')).toBe(true);
expect(find('flyoutTitle').text()).toBe('Create field');
});
test('should allow a field to be provided', () => {
const field = {
name: 'foo',
type: 'ip',
script: {
source: 'emit("hello world")',
},
};
const { find } = setup({ ...defaultProps, field });
expect(find('flyoutTitle').text()).toBe(`Edit ${field.name} field`);
expect(find('nameField.input').props().value).toBe(field.name);
expect(find('typeField').props().value).toBe(field.type);
expect(find('scriptField').props().value).toBe(field.script.source);
});
test('should accept an "onSave" prop', async () => {
const field = {
name: 'foo',
type: 'date',
script: { source: 'test=123' },
};
const onSave: jest.Mock<Props['onSave']> = jest.fn();
const { find } = setup({ ...defaultProps, onSave, field });
await act(async () => {
find('fieldSaveButton').simulate('click');
});
await act(async () => {
// The painless syntax validation has a timeout set to 600ms
// we give it a bit more time just to be on the safe side
jest.advanceTimersByTime(1000);
});
expect(onSave).toHaveBeenCalled();
const fieldReturned = onSave.mock.calls[onSave.mock.calls.length - 1][0];
expect(fieldReturned).toEqual(field);
});
test('should accept an onCancel prop', () => {
const onCancel = jest.fn();
const { find } = setup({ ...defaultProps, onCancel });
find('closeFlyoutButton').simulate('click');
expect(onCancel).toHaveBeenCalled();
});
describe('validation', () => {
test('should validate the fields and prevent saving invalid form', async () => {
const onSave: jest.Mock<Props['onSave']> = jest.fn();
const { find, exists, form, component } = setup({ ...defaultProps, onSave });
expect(find('fieldSaveButton').props().disabled).toBe(false);
await act(async () => {
find('fieldSaveButton').simulate('click');
});
await act(async () => {
jest.advanceTimersByTime(1000);
});
component.update();
expect(onSave).toHaveBeenCalledTimes(0);
expect(find('fieldSaveButton').props().disabled).toBe(true);
expect(form.getErrorsMessages()).toEqual(['A name is required.']);
expect(exists('formError')).toBe(true);
expect(find('formError').text()).toBe('Fix errors in form before continuing.');
});
test('should forward values from the form', async () => {
const onSave: jest.Mock<Props['onSave']> = jest.fn();
const {
find,
component,
form,
actions: { toggleFormRow },
} = setup({ ...defaultProps, onSave });
act(() => {
form.setInputValue('nameField.input', 'someName');
toggleFormRow('value');
});
component.update();
await act(async () => {
form.setInputValue('scriptField', 'echo("hello")');
});
await act(async () => {
// Let's make sure that validation has finished running
jest.advanceTimersByTime(1000);
});
await act(async () => {
find('fieldSaveButton').simulate('click');
});
expect(onSave).toHaveBeenCalled();
let fieldReturned = onSave.mock.calls[onSave.mock.calls.length - 1][0];
expect(fieldReturned).toEqual({
name: 'someName',
type: 'keyword', // default to keyword
script: { source: 'echo("hello")' },
});
// Change the type and make sure it is forwarded
act(() => {
find('typeField').simulate('change', [
{
label: 'Other type',
value: 'other_type',
},
]);
});
await act(async () => {
find('fieldSaveButton').simulate('click');
});
fieldReturned = onSave.mock.calls[onSave.mock.calls.length - 1][0];
expect(fieldReturned).toEqual({
name: 'someName',
type: 'other_type',
script: { source: 'echo("hello")' },
});
});
});
});

View file

@ -0,0 +1,253 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFlyoutHeader,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
EuiCallOut,
EuiSpacer,
} from '@elastic/eui';
import { DocLinksStart, CoreStart } from 'src/core/public';
import { Field, InternalFieldType, PluginStart, EsRuntimeField } from '../types';
import { getLinks, RuntimeFieldPainlessError } from '../lib';
import type { IndexPattern, DataPublicPluginStart } from '../shared_imports';
import type { Props as FieldEditorProps, FieldEditorFormState } from './field_editor/field_editor';
const geti18nTexts = (field?: Field) => {
return {
flyoutTitle: field
? i18n.translate('indexPatternFieldEditor.editor.flyoutEditFieldTitle', {
defaultMessage: 'Edit {fieldName} field',
values: {
fieldName: field.name,
},
})
: i18n.translate('indexPatternFieldEditor.editor.flyoutDefaultTitle', {
defaultMessage: 'Create field',
}),
closeButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCloseButtonLabel', {
defaultMessage: 'Close',
}),
saveButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutSaveButtonLabel', {
defaultMessage: 'Save',
}),
formErrorsCalloutTitle: i18n.translate('indexPatternFieldEditor.editor.validationErrorTitle', {
defaultMessage: 'Fix errors in form before continuing.',
}),
};
};
export interface Props {
/**
* Handler for the "save" footer button
*/
onSave: (field: Field) => void;
/**
* Handler for the "cancel" footer button
*/
onCancel: () => void;
/**
* The docLinks start service from core
*/
docLinks: DocLinksStart;
/**
* The Field editor component that contains the form to create or edit a field
*/
FieldEditor: React.ComponentType<FieldEditorProps> | null;
/** The internal field type we are dealing with (concrete|runtime)*/
fieldTypeToProcess: InternalFieldType;
/** Handler to validate the script */
runtimeFieldValidator: (field: EsRuntimeField) => Promise<RuntimeFieldPainlessError | null>;
/** Optional field to process */
field?: Field;
indexPattern: IndexPattern;
fieldFormatEditors: PluginStart['fieldFormatEditors'];
fieldFormats: DataPublicPluginStart['fieldFormats'];
uiSettings: CoreStart['uiSettings'];
isSavingField: boolean;
}
const FieldEditorFlyoutContentComponent = ({
field,
onSave,
onCancel,
FieldEditor,
docLinks,
indexPattern,
fieldFormatEditors,
fieldFormats,
uiSettings,
fieldTypeToProcess,
runtimeFieldValidator,
isSavingField,
}: Props) => {
const i18nTexts = geti18nTexts(field);
const [formState, setFormState] = useState<FieldEditorFormState>({
isSubmitted: false,
isValid: field ? true : undefined,
submit: field
? async () => ({ isValid: true, data: field })
: async () => ({ isValid: false, data: {} as Field }),
});
const [painlessSyntaxError, setPainlessSyntaxError] = useState<RuntimeFieldPainlessError | null>(
null
);
const [isValidating, setIsValidating] = useState(false);
const { submit, isValid: isFormValid, isSubmitted } = formState;
const { fields } = indexPattern;
const isSaveButtonDisabled = isFormValid === false || painlessSyntaxError !== null;
const clearSyntaxError = useCallback(() => setPainlessSyntaxError(null), []);
const syntaxError = useMemo(
() => ({
error: painlessSyntaxError,
clear: clearSyntaxError,
}),
[painlessSyntaxError, clearSyntaxError]
);
const onClickSave = useCallback(async () => {
const { isValid, data } = await submit();
if (isValid) {
if (data.script) {
setIsValidating(true);
const error = await runtimeFieldValidator({
type: data.type,
script: data.script,
});
setIsValidating(false);
setPainlessSyntaxError(error);
if (error) {
return;
}
}
onSave(data);
}
}, [onSave, submit, runtimeFieldValidator]);
const namesNotAllowed = useMemo(() => fields.map((fld) => fld.name), [fields]);
const existingConcreteFields = useMemo(() => {
const existing: Array<{ name: string; type: string }> = [];
fields
.filter((fld) => {
const isFieldBeingEdited = field?.name === fld.name;
return !isFieldBeingEdited && fld.isMapped;
})
.forEach((fld) => {
existing.push({
name: fld.name,
type: (fld.esTypes && fld.esTypes[0]) || '',
});
});
return existing;
}, [fields, field]);
const ctx = useMemo(
() => ({
fieldTypeToProcess,
namesNotAllowed,
existingConcreteFields,
}),
[fieldTypeToProcess, namesNotAllowed, existingConcreteFields]
);
return (
<>
<EuiFlyoutHeader>
<EuiTitle size="m" data-test-subj="flyoutTitle">
<h2 id="fieldEditorTitle">{i18nTexts.flyoutTitle}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{FieldEditor && (
<FieldEditor
indexPattern={indexPattern}
fieldFormatEditors={fieldFormatEditors}
fieldFormats={fieldFormats}
uiSettings={uiSettings}
links={getLinks(docLinks)}
field={field}
onChange={setFormState}
ctx={ctx}
syntaxError={syntaxError}
/>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
{FieldEditor && (
<>
{isSubmitted && isSaveButtonDisabled && (
<>
<EuiCallOut
title={i18nTexts.formErrorsCalloutTitle}
color="danger"
iconType="cross"
data-test-subj="formError"
/>
<EuiSpacer size="m" />
</>
)}
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={onCancel}
data-test-subj="closeFlyoutButton"
>
{i18nTexts.closeButtonLabel}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
onClick={onClickSave}
data-test-subj="fieldSaveButton"
fill
disabled={isSaveButtonDisabled}
isLoading={isSavingField || isValidating}
>
{i18nTexts.saveButtonLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</EuiFlyoutFooter>
</>
);
};
export const FieldEditorFlyoutContent = React.memo(FieldEditorFlyoutContentComponent);

View file

@ -0,0 +1,205 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { DocLinksStart, NotificationsStart, CoreStart } from 'src/core/public';
import { i18n } from '@kbn/i18n';
import {
IndexPatternField,
IndexPattern,
DataPublicPluginStart,
RuntimeType,
UsageCollectionStart,
} from '../shared_imports';
import { Field, PluginStart, InternalFieldType } from '../types';
import { pluginName } from '../constants';
import { deserializeField, getRuntimeFieldValidator } from '../lib';
import { Props as FieldEditorProps } from './field_editor/field_editor';
import { FieldEditorFlyoutContent } from './field_editor_flyout_content';
export interface FieldEditorContext {
indexPattern: IndexPattern;
/**
* The Kibana field type of the field to create or edit
* Default: "runtime"
*/
fieldTypeToProcess: InternalFieldType;
/** The search service from the data plugin */
search: DataPublicPluginStart['search'];
}
export interface Props {
/**
* Handler for the "save" footer button
*/
onSave: (field: IndexPatternField) => void;
/**
* Handler for the "cancel" footer button
*/
onCancel: () => void;
/**
* The docLinks start service from core
*/
docLinks: DocLinksStart;
/**
* The context object specific to where the editor is currently being consumed
*/
ctx: FieldEditorContext;
/**
* Optional field to edit
*/
field?: IndexPatternField;
/**
* Services
*/
indexPatternService: DataPublicPluginStart['indexPatterns'];
notifications: NotificationsStart;
fieldFormatEditors: PluginStart['fieldFormatEditors'];
fieldFormats: DataPublicPluginStart['fieldFormats'];
uiSettings: CoreStart['uiSettings'];
usageCollection: UsageCollectionStart;
}
/**
* The container component will be in charge of the communication with the index pattern service
* to retrieve/save the field in the saved object.
* The <FieldEditorFlyoutContent /> component is the presentational component that won't know
* anything about where a field comes from and where it should be persisted.
*/
export const FieldEditorFlyoutContentContainer = ({
field,
onSave,
onCancel,
docLinks,
indexPatternService,
ctx: { indexPattern, fieldTypeToProcess, search },
notifications,
fieldFormatEditors,
fieldFormats,
uiSettings,
usageCollection,
}: Props) => {
const fieldToEdit = deserializeField(indexPattern, field);
const [Editor, setEditor] = useState<React.ComponentType<FieldEditorProps> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const saveField = useCallback(
async (updatedField: Field) => {
setIsSaving(true);
const { script } = updatedField;
if (fieldTypeToProcess === 'runtime') {
try {
usageCollection.reportUiCounter(
pluginName,
usageCollection.METRIC_TYPE.COUNT,
'save_runtime'
);
// eslint-disable-next-line no-empty
} catch {}
// rename an existing runtime field
if (field?.name && field.name !== updatedField.name) {
indexPattern.removeRuntimeField(field.name);
}
indexPattern.addRuntimeField(updatedField.name, {
type: updatedField.type as RuntimeType,
script,
});
} else {
try {
usageCollection.reportUiCounter(
pluginName,
usageCollection.METRIC_TYPE.COUNT,
'save_concrete'
);
// eslint-disable-next-line no-empty
} catch {}
}
const editedField = indexPattern.getFieldByName(updatedField.name);
try {
if (!editedField) {
throw new Error(
`Unable to find field named '${updatedField.name}' on index pattern '${indexPattern.title}'`
);
}
indexPattern.setFieldCustomLabel(updatedField.name, updatedField.customLabel);
editedField.count = updatedField.popularity || 0;
if (updatedField.format) {
indexPattern.setFieldFormat(updatedField.name, updatedField.format);
} else {
indexPattern.deleteFieldFormat(updatedField.name);
}
await indexPatternService.updateSavedObject(indexPattern).then(() => {
const message = i18n.translate('indexPatternFieldEditor.deleteField.savedHeader', {
defaultMessage: "Saved '{fieldName}'",
values: { fieldName: updatedField.name },
});
notifications.toasts.addSuccess(message);
setIsSaving(false);
onSave(editedField);
});
} catch (e) {
const title = i18n.translate('indexPatternFieldEditor.save.errorTitle', {
defaultMessage: 'Failed to save field changes',
});
notifications.toasts.addError(e, { title });
setIsSaving(false);
}
},
[
onSave,
indexPattern,
indexPatternService,
notifications,
fieldTypeToProcess,
field?.name,
usageCollection,
]
);
const validateRuntimeField = useMemo(() => getRuntimeFieldValidator(indexPattern.title, search), [
search,
indexPattern,
]);
const loadEditor = useCallback(async () => {
const { FieldEditor } = await import('./field_editor');
setEditor(() => FieldEditor);
}, []);
useEffect(() => {
// On mount: load the editor asynchronously
loadEditor();
}, [loadEditor]);
return (
<FieldEditorFlyoutContent
onSave={saveField}
onCancel={onCancel}
docLinks={docLinks}
field={fieldToEdit}
FieldEditor={Editor}
fieldFormatEditors={fieldFormatEditors}
fieldFormats={fieldFormats}
uiSettings={uiSettings}
indexPattern={indexPattern}
fieldTypeToProcess={fieldTypeToProcess}
runtimeFieldValidator={validateRuntimeField}
isSavingField={isSaving}
/>
);
};

View file

@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FieldFormatEditor should render normally 1`] = `
<Fragment>
<TestEditor
fieldType="number"
format={Object {}}
formatParams={Object {}}
onChange={[Function]}
onError={[Function]}
/>
</Fragment>
`;
exports[`FieldFormatEditor should render nothing if there is no editor for the format 1`] = `
<Fragment>
<TestEditor
fieldType="number"
format={Object {}}
formatParams={Object {}}
onChange={[Function]}
onError={[Function]}
/>
</Fragment>
`;

View file

@ -16,7 +16,7 @@ exports[`BytesFormatEditor should render normally 1`] = `
>
<FormattedMessage
defaultMessage="Documentation"
id="indexPatternManagement.number.documentationLabel"
id="indexPatternFieldEditor.number.documentationLabel"
values={Object {}}
/>
 
@ -30,7 +30,7 @@ exports[`BytesFormatEditor should render normally 1`] = `
label={
<FormattedMessage
defaultMessage="Numeral.js format pattern (Default: {defaultPattern})"
id="indexPatternManagement.number.numeralLabel"
id="indexPatternFieldEditor.number.numeralLabel"
values={
Object {
"defaultPattern": <EuiCode>

View file

@ -9,7 +9,7 @@ exports[`ColorFormatEditor should render multiple colors 1`] = `
"field": "regex",
"name": <FormattedMessage
defaultMessage="Pattern (regular expression)"
id="indexPatternManagement.color.patternLabel"
id="indexPatternFieldEditor.color.patternLabel"
values={Object {}}
/>,
"render": [Function],
@ -18,7 +18,7 @@ exports[`ColorFormatEditor should render multiple colors 1`] = `
"field": "text",
"name": <FormattedMessage
defaultMessage="Text color"
id="indexPatternManagement.color.textColorLabel"
id="indexPatternFieldEditor.color.textColorLabel"
values={Object {}}
/>,
"render": [Function],
@ -27,7 +27,7 @@ exports[`ColorFormatEditor should render multiple colors 1`] = `
"field": "background",
"name": <FormattedMessage
defaultMessage="Background color"
id="indexPatternManagement.color.backgroundLabel"
id="indexPatternFieldEditor.color.backgroundLabel"
values={Object {}}
/>,
"render": [Function],
@ -35,7 +35,7 @@ exports[`ColorFormatEditor should render multiple colors 1`] = `
Object {
"name": <FormattedMessage
defaultMessage="Example"
id="indexPatternManagement.color.exampleLabel"
id="indexPatternFieldEditor.color.exampleLabel"
values={Object {}}
/>,
"render": [Function],
@ -89,7 +89,7 @@ exports[`ColorFormatEditor should render multiple colors 1`] = `
>
<FormattedMessage
defaultMessage="Add color"
id="indexPatternManagement.color.addColorButton"
id="indexPatternFieldEditor.color.addColorButton"
values={Object {}}
/>
</EuiButton>
@ -108,7 +108,7 @@ exports[`ColorFormatEditor should render other type normally (range field) 1`] =
"field": "range",
"name": <FormattedMessage
defaultMessage="Range (min:max)"
id="indexPatternManagement.color.rangeLabel"
id="indexPatternFieldEditor.color.rangeLabel"
values={Object {}}
/>,
"render": [Function],
@ -117,7 +117,7 @@ exports[`ColorFormatEditor should render other type normally (range field) 1`] =
"field": "text",
"name": <FormattedMessage
defaultMessage="Text color"
id="indexPatternManagement.color.textColorLabel"
id="indexPatternFieldEditor.color.textColorLabel"
values={Object {}}
/>,
"render": [Function],
@ -126,7 +126,7 @@ exports[`ColorFormatEditor should render other type normally (range field) 1`] =
"field": "background",
"name": <FormattedMessage
defaultMessage="Background color"
id="indexPatternManagement.color.backgroundLabel"
id="indexPatternFieldEditor.color.backgroundLabel"
values={Object {}}
/>,
"render": [Function],
@ -134,7 +134,7 @@ exports[`ColorFormatEditor should render other type normally (range field) 1`] =
Object {
"name": <FormattedMessage
defaultMessage="Example"
id="indexPatternManagement.color.exampleLabel"
id="indexPatternFieldEditor.color.exampleLabel"
values={Object {}}
/>,
"render": [Function],
@ -181,7 +181,7 @@ exports[`ColorFormatEditor should render other type normally (range field) 1`] =
>
<FormattedMessage
defaultMessage="Add color"
id="indexPatternManagement.color.addColorButton"
id="indexPatternFieldEditor.color.addColorButton"
values={Object {}}
/>
</EuiButton>
@ -200,7 +200,7 @@ exports[`ColorFormatEditor should render string type normally (regex field) 1`]
"field": "regex",
"name": <FormattedMessage
defaultMessage="Pattern (regular expression)"
id="indexPatternManagement.color.patternLabel"
id="indexPatternFieldEditor.color.patternLabel"
values={Object {}}
/>,
"render": [Function],
@ -209,7 +209,7 @@ exports[`ColorFormatEditor should render string type normally (regex field) 1`]
"field": "text",
"name": <FormattedMessage
defaultMessage="Text color"
id="indexPatternManagement.color.textColorLabel"
id="indexPatternFieldEditor.color.textColorLabel"
values={Object {}}
/>,
"render": [Function],
@ -218,7 +218,7 @@ exports[`ColorFormatEditor should render string type normally (regex field) 1`]
"field": "background",
"name": <FormattedMessage
defaultMessage="Background color"
id="indexPatternManagement.color.backgroundLabel"
id="indexPatternFieldEditor.color.backgroundLabel"
values={Object {}}
/>,
"render": [Function],
@ -226,7 +226,7 @@ exports[`ColorFormatEditor should render string type normally (regex field) 1`]
Object {
"name": <FormattedMessage
defaultMessage="Example"
id="indexPatternManagement.color.exampleLabel"
id="indexPatternFieldEditor.color.exampleLabel"
values={Object {}}
/>,
"render": [Function],
@ -273,7 +273,7 @@ exports[`ColorFormatEditor should render string type normally (regex field) 1`]
>
<FormattedMessage
defaultMessage="Add color"
id="indexPatternManagement.color.addColorButton"
id="indexPatternFieldEditor.color.addColorButton"
values={Object {}}
/>
</EuiButton>

View file

@ -11,7 +11,7 @@ import { shallowWithI18nProvider } from '@kbn/test/jest';
import { FieldFormat } from 'src/plugins/data/public';
import { ColorFormatEditor } from './color';
import { fieldFormats } from '../../../../../../../../data/public';
import { fieldFormats } from '../../../../../../data/public';
const fieldType = 'string';
const format = {

View file

@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { DefaultFormatEditor, FormatEditorProps } from '../default';
import { fieldFormats } from '../../../../../../../../../plugins/data/public';
import { fieldFormats } from '../../../../../../data/public';
interface Color {
range?: string;
@ -86,7 +86,7 @@ export class ColorFormatEditor extends DefaultFormatEditor<ColorFormatEditorForm
field: 'regex',
name: (
<FormattedMessage
id="indexPatternManagement.color.patternLabel"
id="indexPatternFieldEditor.color.patternLabel"
defaultMessage="Pattern (regular expression)"
/>
),
@ -110,7 +110,7 @@ export class ColorFormatEditor extends DefaultFormatEditor<ColorFormatEditorForm
field: 'range',
name: (
<FormattedMessage
id="indexPatternManagement.color.rangeLabel"
id="indexPatternFieldEditor.color.rangeLabel"
defaultMessage="Range (min:max)"
/>
),
@ -134,7 +134,7 @@ export class ColorFormatEditor extends DefaultFormatEditor<ColorFormatEditorForm
field: 'text',
name: (
<FormattedMessage
id="indexPatternManagement.color.textColorLabel"
id="indexPatternFieldEditor.color.textColorLabel"
defaultMessage="Text color"
/>
),
@ -158,7 +158,7 @@ export class ColorFormatEditor extends DefaultFormatEditor<ColorFormatEditorForm
field: 'background',
name: (
<FormattedMessage
id="indexPatternManagement.color.backgroundLabel"
id="indexPatternFieldEditor.color.backgroundLabel"
defaultMessage="Background color"
/>
),
@ -181,7 +181,7 @@ export class ColorFormatEditor extends DefaultFormatEditor<ColorFormatEditorForm
{
name: (
<FormattedMessage
id="indexPatternManagement.color.exampleLabel"
id="indexPatternFieldEditor.color.exampleLabel"
defaultMessage="Example"
/>
),
@ -200,15 +200,15 @@ export class ColorFormatEditor extends DefaultFormatEditor<ColorFormatEditorForm
},
{
field: 'actions',
name: i18n.translate('indexPatternManagement.color.actions', {
name: i18n.translate('indexPatternFieldEditor.color.actions', {
defaultMessage: 'Actions',
}),
actions: [
{
name: i18n.translate('indexPatternManagement.color.deleteAria', {
name: i18n.translate('indexPatternFieldEditor.color.deleteAria', {
defaultMessage: 'Delete',
}),
description: i18n.translate('indexPatternManagement.color.deleteTitle', {
description: i18n.translate('indexPatternFieldEditor.color.deleteTitle', {
defaultMessage: 'Delete color format',
}),
onClick: (item: IndexedColor) => {
@ -229,7 +229,7 @@ export class ColorFormatEditor extends DefaultFormatEditor<ColorFormatEditorForm
<EuiSpacer size="m" />
<EuiButton iconType="plusInCircle" size="s" onClick={this.addColor}>
<FormattedMessage
id="indexPatternManagement.color.addColorButton"
id="indexPatternFieldEditor.color.addColorButton"
defaultMessage="Add color"
/>
</EuiButton>

View file

@ -16,7 +16,7 @@ exports[`DateFormatEditor should render normally 1`] = `
>
<FormattedMessage
defaultMessage="Documentation"
id="indexPatternManagement.date.documentationLabel"
id="indexPatternFieldEditor.date.documentationLabel"
values={Object {}}
/>
 
@ -30,7 +30,7 @@ exports[`DateFormatEditor should render normally 1`] = `
label={
<FormattedMessage
defaultMessage="Moment.js format pattern (Default: {defaultPattern})"
id="indexPatternManagement.date.momentLabel"
id="indexPatternFieldEditor.date.momentLabel"
values={
Object {
"defaultPattern": <EuiCode>

View file

@ -41,7 +41,7 @@ export class DateFormatEditor extends DefaultFormatEditor<DateFormatEditorFormat
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.date.momentLabel"
id="indexPatternFieldEditor.date.momentLabel"
defaultMessage="Moment.js format pattern (Default: {defaultPattern})"
values={{
defaultPattern: <EuiCode>{defaultPattern}</EuiCode>,
@ -54,7 +54,7 @@ export class DateFormatEditor extends DefaultFormatEditor<DateFormatEditorFormat
<span>
<EuiLink target="_blank" href="https://momentjs.com/">
<FormattedMessage
id="indexPatternManagement.date.documentationLabel"
id="indexPatternFieldEditor.date.documentationLabel"
defaultMessage="Documentation"
/>
&nbsp;

View file

@ -16,7 +16,7 @@ exports[`DateFormatEditor should render normally 1`] = `
>
<FormattedMessage
defaultMessage="Documentation"
id="indexPatternManagement.date.documentationLabel"
id="indexPatternFieldEditor.date.documentationLabel"
values={Object {}}
/>
 
@ -30,7 +30,7 @@ exports[`DateFormatEditor should render normally 1`] = `
label={
<FormattedMessage
defaultMessage="Moment.js format pattern (Default: {defaultPattern})"
id="indexPatternManagement.date.momentLabel"
id="indexPatternFieldEditor.date.momentLabel"
values={
Object {
"defaultPattern": <EuiCode>

View file

@ -8,7 +8,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { FieldFormat } from '../../../../../../../../data/public';
import type { FieldFormat } from 'src/plugins/data/public';
import { DateNanosFormatEditor } from './date_nanos';

View file

@ -40,7 +40,7 @@ export class DateNanosFormatEditor extends DefaultFormatEditor<DateNanosFormatEd
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.date.momentLabel"
id="indexPatternFieldEditor.date.momentLabel"
defaultMessage="Moment.js format pattern (Default: {defaultPattern})"
values={{
defaultPattern: <EuiCode>{defaultPattern}</EuiCode>,
@ -53,7 +53,7 @@ export class DateNanosFormatEditor extends DefaultFormatEditor<DateNanosFormatEd
<span>
<EuiLink target="_blank" href="https://momentjs.com/">
<FormattedMessage
id="indexPatternManagement.date.documentationLabel"
id="indexPatternFieldEditor.date.documentationLabel"
defaultMessage="Documentation"
/>
&nbsp;

View file

@ -10,8 +10,8 @@ import React, { PureComponent, ReactText } from 'react';
import { i18n } from '@kbn/i18n';
import { FieldFormat, FieldFormatsContentType } from 'src/plugins/data/public';
import { Sample } from '../../../../types';
import { FieldFormatEditorProps } from '../../field_format_editor';
import { Sample } from '../../types';
import { FormatSelectEditorProps } from '../../field_format_editor';
export type ConverterParams = string | number | Array<string | number>;
@ -30,7 +30,7 @@ export const convertSampleInput = (
};
});
} catch (e) {
error = i18n.translate('indexPatternManagement.defaultErrorMessage', {
error = i18n.translate('indexPatternFieldEditor.defaultErrorMessage', {
defaultMessage: 'An error occurred while trying to use this format configuration: {message}',
values: { message: e.message },
});
@ -51,7 +51,7 @@ export interface FormatEditorProps<P> {
format: FieldFormat;
formatParams: { type?: string } & P;
onChange: (newParams: Record<string, any>) => void;
onError: FieldFormatEditorProps['onError'];
onError: FormatSelectEditorProps['onError'];
}
export interface FormatEditorState {

View file

@ -12,7 +12,7 @@ exports[`DurationFormatEditor should render human readable output normally 1`] =
label={
<FormattedMessage
defaultMessage="Input format"
id="indexPatternManagement.duration.inputFormatLabel"
id="indexPatternFieldEditor.duration.inputFormatLabel"
values={Object {}}
/>
}
@ -42,7 +42,7 @@ exports[`DurationFormatEditor should render human readable output normally 1`] =
label={
<FormattedMessage
defaultMessage="Output format"
id="indexPatternManagement.duration.outputFormatLabel"
id="indexPatternFieldEditor.duration.outputFormatLabel"
values={Object {}}
/>
}
@ -124,7 +124,7 @@ exports[`DurationFormatEditor should render non-human readable output normally 1
label={
<FormattedMessage
defaultMessage="Input format"
id="indexPatternManagement.duration.inputFormatLabel"
id="indexPatternFieldEditor.duration.inputFormatLabel"
values={Object {}}
/>
}
@ -154,7 +154,7 @@ exports[`DurationFormatEditor should render non-human readable output normally 1
label={
<FormattedMessage
defaultMessage="Output format"
id="indexPatternManagement.duration.outputFormatLabel"
id="indexPatternFieldEditor.duration.outputFormatLabel"
values={Object {}}
/>
}
@ -189,7 +189,7 @@ exports[`DurationFormatEditor should render non-human readable output normally 1
label={
<FormattedMessage
defaultMessage="Decimal places"
id="indexPatternManagement.duration.decimalPlacesLabel"
id="indexPatternFieldEditor.duration.decimalPlacesLabel"
values={Object {}}
/>
}
@ -216,7 +216,7 @@ exports[`DurationFormatEditor should render non-human readable output normally 1
label={
<FormattedMessage
defaultMessage="Show suffix"
id="indexPatternManagement.duration.showSuffixLabel"
id="indexPatternFieldEditor.duration.showSuffixLabel"
values={Object {}}
/>
}

View file

@ -65,7 +65,7 @@ export class DurationFormatEditor extends DefaultFormatEditor<
!(nextProps.format as DurationFormat).isHuman() &&
nextProps.formatParams.outputPrecision > 20
) {
error = i18n.translate('indexPatternManagement.durationErrorMessage', {
error = i18n.translate('indexPatternFieldEditor.durationErrorMessage', {
defaultMessage: 'Decimal places must be between 0 and 20',
});
nextProps.onError(error);
@ -91,7 +91,7 @@ export class DurationFormatEditor extends DefaultFormatEditor<
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.duration.inputFormatLabel"
id="indexPatternFieldEditor.duration.inputFormatLabel"
defaultMessage="Input format"
/>
}
@ -115,7 +115,7 @@ export class DurationFormatEditor extends DefaultFormatEditor<
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.duration.outputFormatLabel"
id="indexPatternFieldEditor.duration.outputFormatLabel"
defaultMessage="Output format"
/>
}
@ -140,7 +140,7 @@ export class DurationFormatEditor extends DefaultFormatEditor<
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.duration.decimalPlacesLabel"
id="indexPatternFieldEditor.duration.decimalPlacesLabel"
defaultMessage="Decimal places"
/>
}
@ -163,7 +163,7 @@ export class DurationFormatEditor extends DefaultFormatEditor<
<EuiSwitch
label={
<FormattedMessage
id="indexPatternManagement.duration.showSuffixLabel"
id="indexPatternFieldEditor.duration.showSuffixLabel"
defaultMessage="Show suffix"
/>
}

View file

@ -16,7 +16,7 @@ exports[`NumberFormatEditor should render normally 1`] = `
>
<FormattedMessage
defaultMessage="Documentation"
id="indexPatternManagement.number.documentationLabel"
id="indexPatternFieldEditor.number.documentationLabel"
values={Object {}}
/>
 
@ -30,7 +30,7 @@ exports[`NumberFormatEditor should render normally 1`] = `
label={
<FormattedMessage
defaultMessage="Numeral.js format pattern (Default: {defaultPattern})"
id="indexPatternManagement.number.numeralLabel"
id="indexPatternFieldEditor.number.numeralLabel"
values={
Object {
"defaultPattern": <EuiCode>

View file

@ -36,7 +36,7 @@ export class NumberFormatEditor extends DefaultFormatEditor<NumberFormatEditorPa
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.number.numeralLabel"
id="indexPatternFieldEditor.number.numeralLabel"
defaultMessage="Numeral.js format pattern (Default: {defaultPattern})"
values={{ defaultPattern: <EuiCode>{defaultPattern}</EuiCode> }}
/>
@ -45,7 +45,7 @@ export class NumberFormatEditor extends DefaultFormatEditor<NumberFormatEditorPa
<span>
<EuiLink target="_blank" href="https://adamwdraper.github.io/Numeral-js/">
<FormattedMessage
id="indexPatternManagement.number.documentationLabel"
id="indexPatternFieldEditor.number.documentationLabel"
defaultMessage="Documentation"
/>
&nbsp;

View file

@ -16,7 +16,7 @@ exports[`PercentFormatEditor should render normally 1`] = `
>
<FormattedMessage
defaultMessage="Documentation"
id="indexPatternManagement.number.documentationLabel"
id="indexPatternFieldEditor.number.documentationLabel"
values={Object {}}
/>
 
@ -30,7 +30,7 @@ exports[`PercentFormatEditor should render normally 1`] = `
label={
<FormattedMessage
defaultMessage="Numeral.js format pattern (Default: {defaultPattern})"
id="indexPatternManagement.number.numeralLabel"
id="indexPatternFieldEditor.number.numeralLabel"
values={
Object {
"defaultPattern": <EuiCode>

View file

@ -8,7 +8,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { FieldFormat } from '../../../../../../../../data/public';
import { FieldFormat } from 'src/plugins/data/public';
import { PercentFormatEditor } from './percent';

View file

@ -9,7 +9,7 @@ exports[`StaticLookupFormatEditor should render multiple lookup entries and unkn
"field": "key",
"name": <FormattedMessage
defaultMessage="Key"
id="indexPatternManagement.staticLookup.keyLabel"
id="indexPatternFieldEditor.staticLookup.keyLabel"
values={Object {}}
/>,
"render": [Function],
@ -18,7 +18,7 @@ exports[`StaticLookupFormatEditor should render multiple lookup entries and unkn
"field": "value",
"name": <FormattedMessage
defaultMessage="Value"
id="indexPatternManagement.staticLookup.valueLabel"
id="indexPatternFieldEditor.staticLookup.valueLabel"
values={Object {}}
/>,
"render": [Function],
@ -73,7 +73,7 @@ exports[`StaticLookupFormatEditor should render multiple lookup entries and unkn
>
<FormattedMessage
defaultMessage="Add entry"
id="indexPatternManagement.staticLookup.addEntryButton"
id="indexPatternFieldEditor.staticLookup.addEntryButton"
values={Object {}}
/>
</EuiButton>
@ -89,7 +89,7 @@ exports[`StaticLookupFormatEditor should render multiple lookup entries and unkn
label={
<FormattedMessage
defaultMessage="Value for unknown key"
id="indexPatternManagement.staticLookup.unknownKeyLabel"
id="indexPatternFieldEditor.staticLookup.unknownKeyLabel"
values={Object {}}
/>
}
@ -116,7 +116,7 @@ exports[`StaticLookupFormatEditor should render normally 1`] = `
"field": "key",
"name": <FormattedMessage
defaultMessage="Key"
id="indexPatternManagement.staticLookup.keyLabel"
id="indexPatternFieldEditor.staticLookup.keyLabel"
values={Object {}}
/>,
"render": [Function],
@ -125,7 +125,7 @@ exports[`StaticLookupFormatEditor should render normally 1`] = `
"field": "value",
"name": <FormattedMessage
defaultMessage="Value"
id="indexPatternManagement.staticLookup.valueLabel"
id="indexPatternFieldEditor.staticLookup.valueLabel"
values={Object {}}
/>,
"render": [Function],
@ -174,7 +174,7 @@ exports[`StaticLookupFormatEditor should render normally 1`] = `
>
<FormattedMessage
defaultMessage="Add entry"
id="indexPatternManagement.staticLookup.addEntryButton"
id="indexPatternFieldEditor.staticLookup.addEntryButton"
values={Object {}}
/>
</EuiButton>
@ -190,7 +190,7 @@ exports[`StaticLookupFormatEditor should render normally 1`] = `
label={
<FormattedMessage
defaultMessage="Value for unknown key"
id="indexPatternManagement.staticLookup.unknownKeyLabel"
id="indexPatternFieldEditor.staticLookup.unknownKeyLabel"
values={Object {}}
/>
}

View file

@ -9,7 +9,7 @@
import React from 'react';
import { shallowWithI18nProvider } from '@kbn/test/jest';
import { StaticLookupFormatEditorFormatParams } from './static_lookup';
import { FieldFormat } from '../../../../../../../../data/public';
import { FieldFormat } from 'src/plugins/data/public';
import { StaticLookupFormatEditor } from './static_lookup';

View file

@ -72,7 +72,7 @@ export class StaticLookupFormatEditor extends DefaultFormatEditor<StaticLookupFo
field: 'key',
name: (
<FormattedMessage
id="indexPatternManagement.staticLookup.keyLabel"
id="indexPatternFieldEditor.staticLookup.keyLabel"
defaultMessage="Key"
/>
),
@ -96,7 +96,7 @@ export class StaticLookupFormatEditor extends DefaultFormatEditor<StaticLookupFo
field: 'value',
name: (
<FormattedMessage
id="indexPatternManagement.staticLookup.valueLabel"
id="indexPatternFieldEditor.staticLookup.valueLabel"
defaultMessage="Value"
/>
),
@ -118,15 +118,15 @@ export class StaticLookupFormatEditor extends DefaultFormatEditor<StaticLookupFo
},
{
field: 'actions',
name: i18n.translate('indexPatternManagement.staticLookup.actions', {
name: i18n.translate('indexPatternFieldEditor.staticLookup.actions', {
defaultMessage: 'actions',
}),
actions: [
{
name: i18n.translate('indexPatternManagement.staticLookup.deleteAria', {
name: i18n.translate('indexPatternFieldEditor.staticLookup.deleteAria', {
defaultMessage: 'Delete',
}),
description: i18n.translate('indexPatternManagement.staticLookup.deleteTitle', {
description: i18n.translate('indexPatternFieldEditor.staticLookup.deleteTitle', {
defaultMessage: 'Delete entry',
}),
onClick: (item: StaticLookupItem) => {
@ -148,7 +148,7 @@ export class StaticLookupFormatEditor extends DefaultFormatEditor<StaticLookupFo
<EuiSpacer size="m" />
<EuiButton iconType="plusInCircle" size="s" onClick={this.addLookup}>
<FormattedMessage
id="indexPatternManagement.staticLookup.addEntryButton"
id="indexPatternFieldEditor.staticLookup.addEntryButton"
defaultMessage="Add entry"
/>
</EuiButton>
@ -156,7 +156,7 @@ export class StaticLookupFormatEditor extends DefaultFormatEditor<StaticLookupFo
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.staticLookup.unknownKeyLabel"
id="indexPatternFieldEditor.staticLookup.unknownKeyLabel"
defaultMessage="Value for unknown key"
/>
}
@ -164,7 +164,7 @@ export class StaticLookupFormatEditor extends DefaultFormatEditor<StaticLookupFo
<EuiFieldText
value={formatParams.unknownKeyValue || ''}
placeholder={i18n.translate(
'indexPatternManagement.staticLookup.leaveBlankPlaceholder',
'indexPatternFieldEditor.staticLookup.leaveBlankPlaceholder',
{
defaultMessage: 'Leave blank to keep value as-is',
}

View file

@ -12,7 +12,7 @@ exports[`StringFormatEditor should render normally 1`] = `
label={
<FormattedMessage
defaultMessage="Transform"
id="indexPatternManagement.string.transformLabel"
id="indexPatternFieldEditor.string.transformLabel"
values={Object {}}
/>
}

View file

@ -47,7 +47,7 @@ export class StringFormatEditor extends DefaultFormatEditor<StringFormatEditorFo
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.string.transformLabel"
id="indexPatternFieldEditor.string.transformLabel"
defaultMessage="Transform"
/>
}

View file

@ -12,7 +12,7 @@ exports[`TruncateFormatEditor should render normally 1`] = `
label={
<FormattedMessage
defaultMessage="Field length"
id="indexPatternManagement.truncate.lengthLabel"
id="indexPatternFieldEditor.truncate.lengthLabel"
values={Object {}}
/>
}

View file

@ -37,7 +37,7 @@ export class TruncateFormatEditor extends DefaultFormatEditor<TruncateFormatEdit
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.truncate.lengthLabel"
id="indexPatternFieldEditor.truncate.lengthLabel"
defaultMessage="Field length"
/>
}

View file

@ -166,14 +166,26 @@ exports[`UrlFormatEditor should render normally 1`] = `
class="euiFormHelpText euiFormRow__text"
id="generated-id-help"
>
<button
<a
class="euiLink euiLink--primary"
type="button"
href="https://www.elastic.co/guide/en/kibana/mocked-test-branch/field-formatters-string.html"
rel="noopener"
target="_blank"
>
<span>
URL template help
</span>
</button>
<span
aria-label="External link"
class="euiLink__externalIcon"
data-euiicon-type="popout"
/>
<span
class="euiScreenReaderOnly"
>
(opens in a new tab or window)
</span>
</a>
</div>
</div>
</div>
@ -217,14 +229,26 @@ exports[`UrlFormatEditor should render normally 1`] = `
class="euiFormHelpText euiFormRow__text"
id="generated-id-help"
>
<button
<a
class="euiLink euiLink--primary"
type="button"
href="https://www.elastic.co/guide/en/kibana/mocked-test-branch/field-formatters-string.html"
rel="noopener"
target="_blank"
>
<span>
Label template help
</span>
</button>
<span
aria-label="External link"
class="euiLink__externalIcon"
data-euiicon-type="popout"
/>
<span
class="euiScreenReaderOnly"
>
(opens in a new tab or window)
</span>
</a>
</div>
</div>
</div>

View file

@ -10,8 +10,8 @@ import React from 'react';
import { FieldFormat } from 'src/plugins/data/public';
import { IntlProvider } from 'react-intl';
import { UrlFormatEditor } from './url';
import { coreMock } from '../../../../../../../../../core/public/mocks';
import { createKibanaReactContext } from '../../../../../../../../kibana_react/public';
import { coreMock } from 'src/core/public/mocks';
import { createKibanaReactContext } from '../../../../../../kibana_react/public';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@ -76,38 +76,6 @@ describe('UrlFormatEditor', () => {
expect(container).toMatchSnapshot();
});
it('should render url template help', async () => {
const { getByText, getByTestId } = renderWithContext(
<UrlFormatEditor
fieldType={fieldType}
format={format}
formatParams={formatParams}
onChange={onChange}
onError={onError}
/>
);
getByText('URL template help');
userEvent.click(getByText('URL template help'));
expect(getByTestId('urlTemplateFlyoutTestSubj')).toBeVisible();
});
it('should render label template help', async () => {
const { getByText, getByTestId } = renderWithContext(
<UrlFormatEditor
fieldType={fieldType}
format={format}
formatParams={formatParams}
onChange={onChange}
onError={onError}
/>
);
getByText('Label template help');
userEvent.click(getByText('Label template help'));
expect(getByTestId('labelTemplateFlyoutTestSubj')).toBeVisible();
});
it('should render width and height fields if image', async () => {
const { getByLabelText } = renderWithContext(
<UrlFormatEditor

View file

@ -22,11 +22,7 @@ import { DefaultFormatEditor, FormatEditorProps } from '../default';
import { FormatEditorSamples } from '../../samples';
import { LabelTemplateFlyout } from './label_template_flyout';
import { UrlTemplateFlyout } from './url_template_flyout';
import type { IndexPatternManagmentContextValue } from '../../../../../../types';
import { context as contextType } from '../../../../../../../../kibana_react/public';
import { context as contextType } from '../../../../../../kibana_react/public';
interface OnChangeParam {
type: string;
@ -59,9 +55,6 @@ export class UrlFormatEditor extends DefaultFormatEditor<
> {
static contextType = contextType;
static formatId = 'url';
// TODO: @kbn/optimizer can't compile this
// declare context: IndexPatternManagmentContextValue;
context: IndexPatternManagmentContextValue | undefined;
private get sampleIconPath() {
const sampleIconPath = `/plugins/indexPatternManagement/assets/icons/{{value}}.png`;
return this.context?.services.http
@ -110,32 +103,6 @@ export class UrlFormatEditor extends DefaultFormatEditor<
this.onChange(params);
};
showUrlTemplateHelp = () => {
this.setState({
showLabelTemplateHelp: false,
showUrlTemplateHelp: true,
});
};
hideUrlTemplateHelp = () => {
this.setState({
showUrlTemplateHelp: false,
});
};
showLabelTemplateHelp = () => {
this.setState({
showLabelTemplateHelp: true,
showUrlTemplateHelp: false,
});
};
hideLabelTemplateHelp = () => {
this.setState({
showLabelTemplateHelp: false,
});
};
renderWidthHeightParameters = () => {
const width = this.sanitizeNumericValue(this.props.formatParams.width);
const height = this.sanitizeNumericValue(this.props.formatParams.height);
@ -143,7 +110,7 @@ export class UrlFormatEditor extends DefaultFormatEditor<
<Fragment>
<EuiFormRow
label={
<FormattedMessage id="indexPatternManagement.url.widthLabel" defaultMessage="Width" />
<FormattedMessage id="indexPatternFieldEditor.url.widthLabel" defaultMessage="Width" />
}
>
<EuiFieldNumber
@ -156,7 +123,10 @@ export class UrlFormatEditor extends DefaultFormatEditor<
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage id="indexPatternManagement.url.heightLabel" defaultMessage="Height" />
<FormattedMessage
id="indexPatternFieldEditor.url.heightLabel"
defaultMessage="Height"
/>
}
>
<EuiFieldNumber
@ -177,17 +147,9 @@ export class UrlFormatEditor extends DefaultFormatEditor<
return (
<Fragment>
<LabelTemplateFlyout
isVisible={this.state.showLabelTemplateHelp}
onClose={this.hideLabelTemplateHelp}
/>
<UrlTemplateFlyout
isVisible={this.state.showUrlTemplateHelp}
onClose={this.hideUrlTemplateHelp}
/>
<EuiFormRow
label={
<FormattedMessage id="indexPatternManagement.url.typeLabel" defaultMessage="Type" />
<FormattedMessage id="indexPatternFieldEditor.url.typeLabel" defaultMessage="Type" />
}
>
<EuiSelect
@ -209,7 +171,7 @@ export class UrlFormatEditor extends DefaultFormatEditor<
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.url.openTabLabel"
id="indexPatternFieldEditor.url.openTabLabel"
defaultMessage="Open in a new tab"
/>
}
@ -217,9 +179,12 @@ export class UrlFormatEditor extends DefaultFormatEditor<
<EuiSwitch
label={
formatParams.openLinkInCurrentTab ? (
<FormattedMessage id="indexPatternManagement.url.offLabel" defaultMessage="Off" />
<FormattedMessage
id="indexPatternFieldEditor.url.offLabel"
defaultMessage="Off"
/>
) : (
<FormattedMessage id="indexPatternManagement.url.onLabel" defaultMessage="On" />
<FormattedMessage id="indexPatternFieldEditor.url.onLabel" defaultMessage="On" />
)
}
checked={!formatParams.openLinkInCurrentTab}
@ -233,14 +198,17 @@ export class UrlFormatEditor extends DefaultFormatEditor<
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.url.urlTemplateLabel"
id="indexPatternFieldEditor.url.urlTemplateLabel"
defaultMessage="URL template"
/>
}
helpText={
<EuiLink onClick={this.showUrlTemplateHelp}>
<EuiLink
target="_blank"
href={this.context.services.docLinks.links.indexPatterns.fieldFormattersString}
>
<FormattedMessage
id="indexPatternManagement.url.template.helpLinkText"
id="indexPatternFieldEditor.url.template.helpLinkText"
defaultMessage="URL template help"
/>
</EuiLink>
@ -260,14 +228,17 @@ export class UrlFormatEditor extends DefaultFormatEditor<
<EuiFormRow
label={
<FormattedMessage
id="indexPatternManagement.url.labelTemplateLabel"
id="indexPatternFieldEditor.url.labelTemplateLabel"
defaultMessage="Label template"
/>
}
helpText={
<EuiLink onClick={this.showLabelTemplateHelp}>
<EuiLink
target="_blank"
href={this.context.services.docLinks.links.indexPatterns.fieldFormattersString}
>
<FormattedMessage
id="indexPatternManagement.url.labelTemplateHelpText"
id="indexPatternFieldEditor.url.labelTemplateHelpText"
defaultMessage="Label template help"
/>
</EuiLink>

Some files were not shown because too many files have changed in this diff Show more