mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Streams 🌊] Extract schema editor component (#209514)
## 📓 Summary Closes https://github.com/elastic/streams-program/issues/130 This work decouples the `SchemaEditor` component from the business logic used for the stream management schema detail to make this part re-usable with a consistent UX on the enrichment processing part. The core changes of this work are: - Move the new `SchemaEditor` component into its own folder and provide it to the existing stream details section. - Expose event handlers and custom hooks to facilitate interacting with a definition streams. - Refactor internal state to push down those states the consumer doesn't need to know about (editing form, loadings) It is now responsibility of a consumer to adapt into the supported properties, which can of course be extended for upcoming changes. ```tsx <SchemaEditor fields={fields} isLoading={isLoadingDefinition || isLoadingUnmappedFields} stream={definition.stream} onFieldUnmap={unmapField} onFieldUpdate={updateField} onRefreshData={refreshFields} withControls withFieldSimulation withTableActions={!isRootStreamDefinition(definition.stream)} /> ```
This commit is contained in:
parent
6635fe501c
commit
ddf3bdcce3
40 changed files with 1370 additions and 1498 deletions
|
@ -11,3 +11,5 @@ export { FieldIcon } from './src/field_icon';
|
|||
export type { FieldIconProps } from './src/field_icon';
|
||||
export { FieldButton } from './src/field_button';
|
||||
export type { FieldButtonProps, ButtonSize } from './src/field_button';
|
||||
export { FieldNameWithIcon } from './src/field_name_with_icon';
|
||||
export type { FieldNameWithIconProps } from './src/field_name_with_icon';
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FieldNameWithIcon renders an icon when type is passed 1`] = `
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="s"
|
||||
>
|
||||
<FieldIcon
|
||||
type="keyword"
|
||||
/>
|
||||
agent.name
|
||||
</EuiFlexGroup>
|
||||
`;
|
||||
|
||||
exports[`FieldNameWithIcon renders only the name when the type is not passed 1`] = `"agent.name"`;
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { FieldNameWithIcon } from './field_name_with_icon';
|
||||
|
||||
test('FieldNameWithIcon renders an icon when type is passed', () => {
|
||||
const component = shallow(<FieldNameWithIcon name="agent.name" type="keyword" />);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('FieldNameWithIcon renders only the name when the type is not passed', () => {
|
||||
const component = shallow(<FieldNameWithIcon name="agent.name" />);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFlexGroup } from '@elastic/eui';
|
||||
import { FieldIcon, FieldIconProps } from '../field_icon';
|
||||
|
||||
export interface FieldNameWithIconProps {
|
||||
name: string;
|
||||
type?: FieldIconProps['type'];
|
||||
}
|
||||
|
||||
export const FieldNameWithIcon = ({ name, type }: FieldNameWithIconProps) => {
|
||||
return type ? (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s">
|
||||
<FieldIcon type={type} />
|
||||
{name}
|
||||
</EuiFlexGroup>
|
||||
) : (
|
||||
name
|
||||
);
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
export { FieldNameWithIcon } from './field_name_with_icon';
|
||||
export type { FieldNameWithIconProps } from './field_name_with_icon';
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const EMPTY_CONTENT = '-----';
|
||||
|
||||
export const FIELD_TYPE_MAP = {
|
||||
boolean: {
|
||||
label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableBooleanType', {
|
||||
|
@ -25,7 +27,7 @@ export const FIELD_TYPE_MAP = {
|
|||
},
|
||||
match_only_text: {
|
||||
label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableTextType', {
|
||||
defaultMessage: 'Text',
|
||||
defaultMessage: 'Text (match_only_text)',
|
||||
}),
|
||||
},
|
||||
long: {
|
||||
|
@ -43,7 +45,9 @@ export const FIELD_TYPE_MAP = {
|
|||
defaultMessage: 'IP',
|
||||
}),
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type FieldTypeOption = keyof typeof FIELD_TYPE_MAP;
|
||||
|
||||
export const FIELD_STATUS_MAP = {
|
||||
inherited: {
|
||||
|
@ -67,3 +71,31 @@ export const FIELD_STATUS_MAP = {
|
|||
};
|
||||
|
||||
export type FieldStatus = keyof typeof FIELD_STATUS_MAP;
|
||||
|
||||
export const TABLE_COLUMNS = {
|
||||
name: {
|
||||
display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTablenameHeader', {
|
||||
defaultMessage: 'Field',
|
||||
}),
|
||||
},
|
||||
type: {
|
||||
display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTabletypeHeader', {
|
||||
defaultMessage: 'Type',
|
||||
}),
|
||||
},
|
||||
format: {
|
||||
display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableformatHeader', {
|
||||
defaultMessage: 'Format',
|
||||
}),
|
||||
},
|
||||
parent: {
|
||||
display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableFieldParentHeader', {
|
||||
defaultMessage: 'Field Parent (Stream)',
|
||||
}),
|
||||
},
|
||||
status: {
|
||||
display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTablestatusHeader', {
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
},
|
||||
};
|
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { EuiButtonIcon, EuiContextMenu, EuiPopover, useGeneratedHtmlId } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useBoolean } from '@kbn/react-hooks';
|
||||
import { toMountPoint } from '@kbn/react-kibana-mount';
|
||||
import { StreamsAppContextProvider } from '../streams_app_context_provider';
|
||||
import { SchemaEditorFlyout } from './flyout';
|
||||
import { useSchemaEditorContext } from './schema_editor_context';
|
||||
import { SchemaField } from './types';
|
||||
import { UnpromoteFieldModal } from './unpromote_field_modal';
|
||||
import { useKibana } from '../../hooks/use_kibana';
|
||||
|
||||
export const FieldActionsCell = ({ field }: { field: SchemaField }) => {
|
||||
const context = useKibana();
|
||||
const schemaEditorContext = useSchemaEditorContext();
|
||||
|
||||
const { core } = context;
|
||||
|
||||
const contextMenuPopoverId = useGeneratedHtmlId({
|
||||
prefix: 'fieldsTableContextMenuPopover',
|
||||
});
|
||||
|
||||
const [popoverIsOpen, { off: closePopover, toggle }] = useBoolean(false);
|
||||
|
||||
const panels = useMemo(() => {
|
||||
const { onFieldUnmap, onFieldUpdate, stream, withFieldSimulation } = schemaEditorContext;
|
||||
|
||||
let actions = [];
|
||||
|
||||
const openFlyout = (props: { isEditingByDefault: boolean } = { isEditingByDefault: false }) => {
|
||||
const overlay = core.overlays.openFlyout(
|
||||
toMountPoint(
|
||||
<StreamsAppContextProvider context={context}>
|
||||
<SchemaEditorFlyout
|
||||
field={field}
|
||||
onClose={() => overlay.close()}
|
||||
onSave={onFieldUpdate}
|
||||
stream={stream}
|
||||
withFieldSimulation={withFieldSimulation}
|
||||
{...props}
|
||||
/>
|
||||
</StreamsAppContextProvider>,
|
||||
core
|
||||
),
|
||||
{ maxWidth: 500 }
|
||||
);
|
||||
};
|
||||
|
||||
const openUnpromoteModal = () => {
|
||||
const overlay = core.overlays.openModal(
|
||||
toMountPoint(
|
||||
<UnpromoteFieldModal
|
||||
field={field}
|
||||
onClose={() => overlay.close()}
|
||||
onFieldUnmap={onFieldUnmap}
|
||||
/>,
|
||||
core
|
||||
),
|
||||
{ maxWidth: 500 }
|
||||
);
|
||||
};
|
||||
|
||||
const viewFieldAction = {
|
||||
name: i18n.translate('xpack.streams.actions.viewFieldLabel', {
|
||||
defaultMessage: 'View field',
|
||||
}),
|
||||
onClick: () => openFlyout(),
|
||||
};
|
||||
|
||||
switch (field.status) {
|
||||
case 'mapped':
|
||||
actions = [
|
||||
viewFieldAction,
|
||||
{
|
||||
name: i18n.translate('xpack.streams.actions.editFieldLabel', {
|
||||
defaultMessage: 'Edit field',
|
||||
}),
|
||||
onClick: () => openFlyout({ isEditingByDefault: true }),
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.streams.actions.unpromoteFieldLabel', {
|
||||
defaultMessage: 'Unmap field',
|
||||
}),
|
||||
onClick: openUnpromoteModal,
|
||||
},
|
||||
];
|
||||
break;
|
||||
case 'unmapped':
|
||||
actions = [
|
||||
viewFieldAction,
|
||||
{
|
||||
name: i18n.translate('xpack.streams.actions.mapFieldLabel', {
|
||||
defaultMessage: 'Map field',
|
||||
}),
|
||||
onClick: () => openFlyout({ isEditingByDefault: true }),
|
||||
},
|
||||
];
|
||||
break;
|
||||
case 'inherited':
|
||||
actions = [viewFieldAction];
|
||||
break;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
title: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableActionsTitle', {
|
||||
defaultMessage: 'Field actions',
|
||||
}),
|
||||
items: actions.map((action) => ({
|
||||
name: action.name,
|
||||
onClick: () => {
|
||||
action.onClick();
|
||||
closePopover();
|
||||
},
|
||||
})),
|
||||
},
|
||||
];
|
||||
}, [closePopover, context, core, field, schemaEditorContext]);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
id={contextMenuPopoverId}
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.translate(
|
||||
'xpack.streams.streamDetailSchemaEditorFieldsTableActionsTriggerButton',
|
||||
{ defaultMessage: 'Open actions menu' }
|
||||
)}
|
||||
data-test-subj="streamsAppActionsButton"
|
||||
iconType="boxesVertical"
|
||||
onClick={toggle}
|
||||
/>
|
||||
}
|
||||
isOpen={popoverIsOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiContextMenu initialPanelId={0} panels={panels} />
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
|
@ -5,14 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiBadge } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { FIELD_STATUS_MAP, FieldStatus } from './configuration_maps';
|
||||
import { EuiBadge } from '@elastic/eui';
|
||||
import { FieldStatus, FIELD_STATUS_MAP } from './constants';
|
||||
|
||||
export const FieldStatusBadge = ({ status }: { status: FieldStatus }) => {
|
||||
return (
|
||||
<>
|
||||
<EuiBadge color={FIELD_STATUS_MAP[status].color}>{FIELD_STATUS_MAP[status].label}</EuiBadge>
|
||||
</>
|
||||
<EuiBadge color={FIELD_STATUS_MAP[status].color}>{FIELD_STATUS_MAP[status].label}</EuiBadge>
|
||||
);
|
||||
};
|
|
@ -5,19 +5,11 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { FieldDefinitionConfig } from '@kbn/streams-schema';
|
||||
import { FieldIcon } from '@kbn/react-field';
|
||||
import { FIELD_TYPE_MAP } from './configuration_maps';
|
||||
import { FieldNameWithIcon } from '@kbn/react-field';
|
||||
import { FIELD_TYPE_MAP } from './constants';
|
||||
|
||||
export const FieldType = ({ type }: { type: FieldDefinitionConfig['type'] }) => {
|
||||
return (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<FieldIcon type={type} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{`${FIELD_TYPE_MAP[type].label}`}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
return <FieldNameWithIcon name={FIELD_TYPE_MAP[type].label} type={type} />;
|
||||
};
|
|
@ -13,8 +13,8 @@ import {
|
|||
EuiSelectableProps,
|
||||
useGeneratedHtmlId,
|
||||
} from '@elastic/eui';
|
||||
import { useBoolean } from '@kbn/react-hooks';
|
||||
import React from 'react';
|
||||
import useToggle from 'react-use/lib/useToggle';
|
||||
|
||||
export const FilterGroup = ({
|
||||
filterGroupButtonLabel,
|
||||
|
@ -25,7 +25,7 @@ export const FilterGroup = ({
|
|||
items: EuiSelectableOption[];
|
||||
onChange: Required<EuiSelectableProps>['onChange'];
|
||||
}) => {
|
||||
const [isPopoverOpen, togglePopover] = useToggle(false);
|
||||
const [isPopoverOpen, { off: closePopover, toggle }] = useBoolean(false);
|
||||
|
||||
const filterGroupPopoverId = useGeneratedHtmlId({
|
||||
prefix: 'filterGroupPopover',
|
||||
|
@ -35,7 +35,7 @@ export const FilterGroup = ({
|
|||
<EuiFilterButton
|
||||
iconType="arrowDown"
|
||||
badgeColor="success"
|
||||
onClick={togglePopover}
|
||||
onClick={toggle}
|
||||
isSelected={isPopoverOpen}
|
||||
numFilters={items.length}
|
||||
hasActiveFilters={!!items.find((item) => item.checked === 'on')}
|
||||
|
@ -51,14 +51,10 @@ export const FilterGroup = ({
|
|||
id={filterGroupPopoverId}
|
||||
button={button}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={() => togglePopover(false)}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
>
|
||||
<EuiSelectable
|
||||
aria-label={filterGroupButtonLabel}
|
||||
options={items}
|
||||
onChange={(...args) => onChange(...args)}
|
||||
>
|
||||
<EuiSelectable aria-label={filterGroupButtonLabel} options={items} onChange={onChange}>
|
||||
{(list) => (
|
||||
<div
|
||||
css={{
|
|
@ -8,24 +8,19 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { EuiSelectableProps } from '@elastic/eui';
|
||||
import { FIELD_STATUS_MAP } from '../configuration_maps';
|
||||
import { EuiSelectableOption, EuiSelectableProps } from '@elastic/eui';
|
||||
import { FilterGroup } from './filter_group';
|
||||
import { ChangeFilterGroups } from '../hooks/use_query_and_filters';
|
||||
import { FIELD_STATUS_MAP } from '../constants';
|
||||
import { TControlsChangeHandler } from '../hooks/use_controls';
|
||||
import { SchemaFieldStatus } from '../types';
|
||||
|
||||
const BUTTON_LABEL = i18n.translate(
|
||||
'xpack.streams.streamDetailSchemaEditor.fieldStatusFilterGroupButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Status',
|
||||
}
|
||||
{ defaultMessage: 'Status' }
|
||||
);
|
||||
|
||||
export const FieldStatusFilterGroup = ({
|
||||
onChangeFilterGroup,
|
||||
}: {
|
||||
onChangeFilterGroup: ChangeFilterGroups;
|
||||
}) => {
|
||||
const [items, setItems] = useState<Array<{ label: string; key?: string }>>(() =>
|
||||
export const FieldStatusFilterGroup = ({ onChange }: { onChange: TControlsChangeHandler }) => {
|
||||
const [items, setItems] = useState<EuiSelectableOption[]>(() =>
|
||||
Object.entries(FIELD_STATUS_MAP).map(([key, value]) => {
|
||||
return {
|
||||
label: value.label,
|
||||
|
@ -37,13 +32,13 @@ export const FieldStatusFilterGroup = ({
|
|||
const onChangeItems = useCallback<Required<EuiSelectableProps>['onChange']>(
|
||||
(nextItems) => {
|
||||
setItems(nextItems);
|
||||
onChangeFilterGroup({
|
||||
onChange({
|
||||
status: nextItems
|
||||
.filter((nextItem) => nextItem.checked === 'on')
|
||||
.map((item) => item.key as string),
|
||||
.map((item) => item.key as SchemaFieldStatus),
|
||||
});
|
||||
},
|
||||
[onChangeFilterGroup]
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
|
@ -8,24 +8,19 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { EuiSelectableProps } from '@elastic/eui';
|
||||
import { FIELD_TYPE_MAP } from '../configuration_maps';
|
||||
import { EuiSelectableOption, EuiSelectableProps } from '@elastic/eui';
|
||||
import { FilterGroup } from './filter_group';
|
||||
import { ChangeFilterGroups } from '../hooks/use_query_and_filters';
|
||||
import { FIELD_TYPE_MAP } from '../constants';
|
||||
import { TControlsChangeHandler } from '../hooks/use_controls';
|
||||
import { SchemaFieldType } from '../types';
|
||||
|
||||
const BUTTON_LABEL = i18n.translate(
|
||||
'xpack.streams.streamDetailSchemaEditor.fieldTypeFilterGroupButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Type',
|
||||
}
|
||||
{ defaultMessage: 'Type' }
|
||||
);
|
||||
|
||||
export const FieldTypeFilterGroup = ({
|
||||
onChangeFilterGroup,
|
||||
}: {
|
||||
onChangeFilterGroup: ChangeFilterGroups;
|
||||
}) => {
|
||||
const [items, setItems] = useState<Array<{ label: string; key?: string }>>(() =>
|
||||
export const FieldTypeFilterGroup = ({ onChange }: { onChange: TControlsChangeHandler }) => {
|
||||
const [items, setItems] = useState<EuiSelectableOption[]>(() =>
|
||||
Object.entries(FIELD_TYPE_MAP).map(([key, value]) => {
|
||||
return {
|
||||
label: value.label,
|
||||
|
@ -37,13 +32,13 @@ export const FieldTypeFilterGroup = ({
|
|||
const onChangeItems = useCallback<Required<EuiSelectableProps>['onChange']>(
|
||||
(nextItems) => {
|
||||
setItems(nextItems);
|
||||
onChangeFilterGroup({
|
||||
onChange({
|
||||
type: nextItems
|
||||
.filter((nextItem) => nextItem.checked === 'on')
|
||||
.map((item) => item.key as string),
|
||||
.map((item) => item.key as SchemaFieldType),
|
||||
});
|
||||
},
|
||||
[onChangeFilterGroup]
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
|
@ -11,23 +11,17 @@ import { i18n } from '@kbn/i18n';
|
|||
|
||||
const EcsRecommendationText = i18n.translate(
|
||||
'xpack.streams.streamDetailSchemaEditor.ecsRecommendationText',
|
||||
{
|
||||
defaultMessage: 'ECS recommendation',
|
||||
}
|
||||
{ defaultMessage: 'ECS recommendation' }
|
||||
);
|
||||
|
||||
const UknownEcsFieldText = i18n.translate(
|
||||
'xpack.streams.streamDetailSchemaEditor.uknownEcsFieldText',
|
||||
{
|
||||
defaultMessage: 'Not an ECS field',
|
||||
}
|
||||
{ defaultMessage: 'Not an ECS field' }
|
||||
);
|
||||
|
||||
const LoadingText = i18n.translate(
|
||||
'xpack.streams.streamDetailSchemaEditor.ecsRecommendationLoadingText',
|
||||
{
|
||||
defaultMessage: 'Loading...',
|
||||
}
|
||||
{ defaultMessage: 'Loading...' }
|
||||
);
|
||||
|
||||
export const EcsRecommendation = ({
|
|
@ -17,12 +17,12 @@ import React, { useCallback } from 'react';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { FieldDefinitionConfig } from '@kbn/streams-schema';
|
||||
import useToggle from 'react-use/lib/useToggle';
|
||||
import { SchemaEditorEditingState } from '../hooks/use_editing_state';
|
||||
import { SchemaField } from '../types';
|
||||
|
||||
type FieldFormFormatProps = Pick<
|
||||
SchemaEditorEditingState,
|
||||
'nextFieldType' | 'nextFieldFormat' | 'setNextFieldFormat'
|
||||
>;
|
||||
interface FieldFormFormatProps {
|
||||
field: SchemaField;
|
||||
onChange: (format: SchemaField['format']) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_FORMAT = 'strict_date_optional_time||epoch_millis';
|
||||
|
||||
|
@ -42,7 +42,7 @@ export const typeSupportsFormat = (type?: FieldDefinitionConfig['type']) => {
|
|||
};
|
||||
|
||||
export const FieldFormFormat = (props: FieldFormFormatProps) => {
|
||||
if (!typeSupportsFormat(props.nextFieldType)) {
|
||||
if (!typeSupportsFormat(props.field.type)) {
|
||||
return null;
|
||||
}
|
||||
return <FieldFormFormatSelection {...props} />;
|
||||
|
@ -50,13 +50,13 @@ export const FieldFormFormat = (props: FieldFormFormatProps) => {
|
|||
|
||||
const FieldFormFormatSelection = (props: FieldFormFormatProps) => {
|
||||
const [isFreeform, toggleIsFreeform] = useToggle(
|
||||
props.nextFieldFormat !== undefined && !isPopularFormat(props.nextFieldFormat)
|
||||
props.field.format !== undefined && !isPopularFormat(props.field.format)
|
||||
);
|
||||
|
||||
const onToggle = useCallback(
|
||||
(e: EuiSwitchEvent) => {
|
||||
if (!e.target.checked && !isPopularFormat(props.nextFieldFormat)) {
|
||||
props.setNextFieldFormat(undefined);
|
||||
if (!e.target.checked && !isPopularFormat(props.field.format)) {
|
||||
props.onChange(undefined);
|
||||
}
|
||||
toggleIsFreeform();
|
||||
},
|
||||
|
@ -85,13 +85,13 @@ const PopularFormatsSelector = (props: FieldFormFormatProps) => {
|
|||
return (
|
||||
<EuiSelect
|
||||
hasNoInitialSelection={
|
||||
props.nextFieldFormat === undefined || !isPopularFormat(props.nextFieldFormat)
|
||||
props.field.format === undefined || !isPopularFormat(props.field.format)
|
||||
}
|
||||
data-test-subj="streamsAppSchemaEditorFieldFormatPopularFormats"
|
||||
onChange={(event) => {
|
||||
props.setNextFieldFormat(event.target.value as PopularFormatOption);
|
||||
props.onChange(event.target.value as PopularFormatOption);
|
||||
}}
|
||||
value={props.nextFieldFormat}
|
||||
value={props.field.format}
|
||||
options={POPULAR_FORMATS.map((format) => ({
|
||||
text: format,
|
||||
value: format,
|
||||
|
@ -105,8 +105,8 @@ const FreeformFormatInput = (props: FieldFormFormatProps) => {
|
|||
<EuiFieldText
|
||||
data-test-subj="streamsAppFieldFormFormatField"
|
||||
placeholder="yyyy/MM/dd"
|
||||
value={props.nextFieldFormat ?? ''}
|
||||
onChange={(e) => props.setNextFieldFormat(e.target.value)}
|
||||
value={props.field.format ?? ''}
|
||||
onChange={(e) => props.onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui';
|
||||
import React, { useEffect } from 'react';
|
||||
import { EcsRecommendation } from './ecs_recommendation';
|
||||
import { FieldType } from '../field_type';
|
||||
import { useKibana } from '../../../hooks/use_kibana';
|
||||
import { EMPTY_CONTENT, FIELD_TYPE_MAP, FieldTypeOption } from '../constants';
|
||||
import { MappedSchemaField, SchemaField } from '../types';
|
||||
|
||||
export const FieldFormType = ({
|
||||
field,
|
||||
isEditing,
|
||||
onTypeChange,
|
||||
}: {
|
||||
field: SchemaField;
|
||||
isEditing: boolean;
|
||||
onTypeChange: FieldTypeSelectorProps['onChange'];
|
||||
}) => {
|
||||
const { useFieldsMetadata } = useKibana().dependencies.start.fieldsMetadata;
|
||||
|
||||
const { fieldsMetadata, loading } = useFieldsMetadata(
|
||||
{ attributes: ['type'], fieldNames: [field.name] },
|
||||
[field]
|
||||
);
|
||||
|
||||
// Propagate recommendation to state if a type is not already set
|
||||
const recommendation = fieldsMetadata?.[field.name]?.type;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!loading &&
|
||||
recommendation !== undefined &&
|
||||
// Supported type
|
||||
recommendation in FIELD_TYPE_MAP &&
|
||||
!field.type
|
||||
) {
|
||||
onTypeChange(recommendation as MappedSchemaField['type']);
|
||||
}
|
||||
}, [field, loading, recommendation, onTypeChange]);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
{isEditing ? (
|
||||
<FieldTypeSelector value={field.type} onChange={onTypeChange} isLoading={loading} />
|
||||
) : field.type ? (
|
||||
<FieldType type={field.type} />
|
||||
) : (
|
||||
EMPTY_CONTENT
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EcsRecommendation isLoading={loading} recommendation={recommendation} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
interface FieldTypeSelectorProps {
|
||||
isLoading?: boolean;
|
||||
onChange: (value: FieldTypeOption) => void;
|
||||
value?: FieldTypeOption;
|
||||
}
|
||||
|
||||
const FieldTypeSelector = ({ value, onChange, isLoading = false }: FieldTypeSelectorProps) => {
|
||||
return (
|
||||
<EuiSelect
|
||||
isLoading={isLoading}
|
||||
data-test-subj="streamsAppFieldFormTypeSelect"
|
||||
hasNoInitialSelection={!value}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value as FieldTypeOption);
|
||||
}}
|
||||
value={value}
|
||||
options={Object.entries(FIELD_TYPE_MAP).map(([optionKey, optionConfig]) => ({
|
||||
text: optionConfig.label,
|
||||
value: optionKey,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,215 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiIconTip,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import useToggle from 'react-use/lib/useToggle';
|
||||
import { WiredStreamDefinition } from '@kbn/streams-schema';
|
||||
import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router';
|
||||
import { FieldParent } from '../field_parent';
|
||||
import { FieldStatusBadge } from '../field_status';
|
||||
import { FieldFormFormat, typeSupportsFormat } from './field_form_format';
|
||||
import { FieldFormType } from './field_form_type';
|
||||
import { ChildrenAffectedCallout } from './children_affected_callout';
|
||||
import { EMPTY_CONTENT } from '../constants';
|
||||
import { SchemaField } from '../types';
|
||||
|
||||
const title = i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryTitle', {
|
||||
defaultMessage: 'Field summary',
|
||||
});
|
||||
|
||||
const FIELD_SUMMARIES = {
|
||||
fieldStatus: {
|
||||
label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldNameHeader', {
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
},
|
||||
fieldType: {
|
||||
label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldTypeHeader', {
|
||||
defaultMessage: 'Type',
|
||||
}),
|
||||
},
|
||||
fieldFormat: {
|
||||
label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldFormatHeader', {
|
||||
defaultMessage: 'Format',
|
||||
}),
|
||||
},
|
||||
fieldParent: {
|
||||
label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldParentHeader', {
|
||||
defaultMessage: 'Field Parent',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
interface FieldSummaryProps {
|
||||
field: SchemaField;
|
||||
isEditingByDefault: boolean;
|
||||
stream: WiredStreamDefinition;
|
||||
onChange: (field: Partial<SchemaField>) => void;
|
||||
}
|
||||
|
||||
export const FieldSummary = (props: FieldSummaryProps) => {
|
||||
const { field, isEditingByDefault, onChange, stream } = props;
|
||||
|
||||
const router = useStreamsAppRouter();
|
||||
|
||||
const [isEditing, toggleEditMode] = useToggle(isEditingByDefault);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiTitle size="xxs">
|
||||
<span>{title} </span>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{field.status !== 'inherited' && !isEditing ? (
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="streamsAppFieldSummaryEditButton"
|
||||
size="s"
|
||||
color="primary"
|
||||
onClick={toggleEditMode}
|
||||
iconType="pencil"
|
||||
>
|
||||
{i18n.translate('xpack.streams.fieldSummary.editButtonLabel', {
|
||||
defaultMessage: 'Edit',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
) : field.status === 'inherited' ? (
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="streamsAppFieldSummaryOpenInParentButton"
|
||||
size="s"
|
||||
color="primary"
|
||||
iconType="popout"
|
||||
href={router.link('/{key}/{tab}/{subtab}', {
|
||||
path: {
|
||||
key: field.parent,
|
||||
tab: 'management',
|
||||
subtab: 'schemaEditor',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{i18n.translate('xpack.streams.fieldSummary.editInParentButtonLabel', {
|
||||
defaultMessage: 'Edit in parent stream',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiTitle size="xxs">
|
||||
<span>
|
||||
{FIELD_SUMMARIES.fieldStatus.label}{' '}
|
||||
<EuiIconTip
|
||||
type="iInCircle"
|
||||
color="subdued"
|
||||
content={i18n.translate('xpack.streams.fieldSummary.statusTooltip', {
|
||||
defaultMessage:
|
||||
'Indicates whether the field is actively mapped for use in the configuration or remains unmapped and inactive.',
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<FieldStatusBadge status={field.status} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiTitle size="xxs">
|
||||
<span>{FIELD_SUMMARIES.fieldType.label}</span>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={2}>
|
||||
<FieldFormType
|
||||
field={field}
|
||||
isEditing={isEditing}
|
||||
onTypeChange={(type) => onChange({ type })}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
|
||||
{typeSupportsFormat(field.type) && (
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiTitle size="xxs">
|
||||
<span>{FIELD_SUMMARIES.fieldFormat.label}</span>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={2}>
|
||||
{isEditing ? (
|
||||
<FieldFormFormat field={field} onChange={(format) => onChange({ format })} />
|
||||
) : (
|
||||
`${field.format ?? EMPTY_CONTENT}`
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiTitle size="xxs">
|
||||
<span>{FIELD_SUMMARIES.fieldParent.label}</span>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<FieldParent parent={field.parent} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</EuiFlexGroup>
|
||||
{isEditing && stream.ingest.routing.length > 0 ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<ChildrenAffectedCallout childStreams={stream.ingest.routing} />
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiFlyoutFooter,
|
||||
EuiTitle,
|
||||
EuiButton,
|
||||
} from '@elastic/eui';
|
||||
import React, { useReducer } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { WiredStreamDefinition } from '@kbn/streams-schema';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
import { SamplePreviewTable } from './sample_preview_table';
|
||||
import { FieldSummary } from './field_summary';
|
||||
import { SchemaField } from '../types';
|
||||
|
||||
export interface SchemaEditorFlyoutProps {
|
||||
field: SchemaField;
|
||||
isEditingByDefault?: boolean;
|
||||
onClose?: () => void;
|
||||
onSave: (field: SchemaField) => void;
|
||||
stream: WiredStreamDefinition;
|
||||
withFieldSimulation?: boolean;
|
||||
}
|
||||
|
||||
export const SchemaEditorFlyout = ({
|
||||
field,
|
||||
stream,
|
||||
onClose,
|
||||
onSave,
|
||||
isEditingByDefault = false,
|
||||
withFieldSimulation = false,
|
||||
}: SchemaEditorFlyoutProps) => {
|
||||
const [nextField, setNextField] = useReducer(
|
||||
(prev: SchemaField, updated: Partial<SchemaField>) =>
|
||||
({
|
||||
...prev,
|
||||
...updated,
|
||||
} as SchemaField),
|
||||
field
|
||||
);
|
||||
|
||||
const [{ loading: isSaving }, saveChanges] = useAsyncFn(async () => {
|
||||
await onSave(nextField);
|
||||
if (onClose) onClose();
|
||||
}, [nextField, onClose, onSave]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2>{field.name}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
|
||||
<EuiFlyoutBody>
|
||||
<EuiFlexGroup direction="column">
|
||||
<FieldSummary
|
||||
field={nextField}
|
||||
isEditingByDefault={isEditingByDefault}
|
||||
onChange={setNextField}
|
||||
stream={stream}
|
||||
/>
|
||||
{withFieldSimulation && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<SamplePreviewTable stream={stream} nextField={nextField} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutBody>
|
||||
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="streamsAppSchemaEditorFlyoutCloseButton"
|
||||
iconType="cross"
|
||||
onClick={onClose}
|
||||
flush="left"
|
||||
>
|
||||
{i18n.translate('xpack.streams.schemaEditorFlyout.closeButtonLabel', {
|
||||
defaultMessage: 'Cancel',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
<EuiButton
|
||||
data-test-subj="streamsAppSchemaEditorFieldSaveButton"
|
||||
isLoading={isSaving}
|
||||
onClick={saveChanges}
|
||||
>
|
||||
{i18n.translate('xpack.streams.fieldForm.saveButtonLabel', {
|
||||
defaultMessage: 'Save changes',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -6,27 +6,26 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api';
|
||||
import { css } from '@emotion/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiCallOut } from '@elastic/eui';
|
||||
import { NamedFieldDefinitionConfig, WiredStreamGetResponse } from '@kbn/streams-schema';
|
||||
import { NamedFieldDefinitionConfig, WiredStreamDefinition } from '@kbn/streams-schema';
|
||||
import { useKibana } from '../../../hooks/use_kibana';
|
||||
import { getFormattedError } from '../../../util/errors';
|
||||
import { useStreamsAppFetch } from '../../../hooks/use_streams_app_fetch';
|
||||
import { PreviewTable } from '../../preview_table';
|
||||
import { isFullFieldDefinition } from '../hooks/use_editing_state';
|
||||
import { LoadingPanel } from '../../loading_panel';
|
||||
import { SchemaField, isSchemaFieldTyped } from '../types';
|
||||
|
||||
interface SamplePreviewTableProps {
|
||||
definition: WiredStreamGetResponse;
|
||||
nextFieldDefinition?: Partial<NamedFieldDefinitionConfig>;
|
||||
streamsRepositoryClient: StreamsRepositoryClient;
|
||||
stream: WiredStreamDefinition;
|
||||
nextField: SchemaField;
|
||||
}
|
||||
|
||||
export const SamplePreviewTable = (props: SamplePreviewTableProps) => {
|
||||
const { nextFieldDefinition, ...rest } = props;
|
||||
if (isFullFieldDefinition(nextFieldDefinition)) {
|
||||
return <SamplePreviewTableContent nextFieldDefinition={nextFieldDefinition} {...rest} />;
|
||||
const { nextField, ...rest } = props;
|
||||
if (isSchemaFieldTyped(nextField)) {
|
||||
return <SamplePreviewTableContent nextField={nextField} {...rest} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
@ -35,33 +34,32 @@ export const SamplePreviewTable = (props: SamplePreviewTableProps) => {
|
|||
const SAMPLE_DOCUMENTS_TO_SHOW = 20;
|
||||
|
||||
const SamplePreviewTableContent = ({
|
||||
definition,
|
||||
nextFieldDefinition,
|
||||
streamsRepositoryClient,
|
||||
}: SamplePreviewTableProps & { nextFieldDefinition: NamedFieldDefinitionConfig }) => {
|
||||
stream,
|
||||
nextField,
|
||||
}: SamplePreviewTableProps & { nextField: NamedFieldDefinitionConfig }) => {
|
||||
const { streamsRepositoryClient } = useKibana().dependencies.start.streams;
|
||||
|
||||
const { value, loading, error } = useStreamsAppFetch(
|
||||
({ signal }) => {
|
||||
return streamsRepositoryClient.fetch('POST /api/streams/{id}/schema/fields_simulation', {
|
||||
signal,
|
||||
params: {
|
||||
path: {
|
||||
id: definition.stream.name,
|
||||
id: stream.name,
|
||||
},
|
||||
body: {
|
||||
field_definitions: [nextFieldDefinition],
|
||||
field_definitions: [nextField],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[definition.stream.name, nextFieldDefinition, streamsRepositoryClient],
|
||||
{
|
||||
disableToastOnError: true,
|
||||
}
|
||||
[stream.name, nextField, streamsRepositoryClient],
|
||||
{ disableToastOnError: true }
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [nextFieldDefinition.name];
|
||||
}, [nextFieldDefinition.name]);
|
||||
return [nextField.name];
|
||||
}, [nextField.name]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingPanel />;
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useReducer } from 'react';
|
||||
import { EuiSearchBar } from '@elastic/eui';
|
||||
import { SchemaFieldStatus, MappedSchemaField } from '../types';
|
||||
|
||||
const defaultControls = {
|
||||
query: EuiSearchBar.Query.MATCH_ALL,
|
||||
status: [] as SchemaFieldStatus[],
|
||||
type: [] as Array<MappedSchemaField['type']>,
|
||||
} as const;
|
||||
|
||||
export type TControls = typeof defaultControls;
|
||||
|
||||
const mergeReducer = (prev: TControls, updated: Partial<TControls>) => ({ ...prev, ...updated });
|
||||
|
||||
export const useControls = () => useReducer(mergeReducer, defaultControls);
|
||||
|
||||
export type TControlsChangeHandler = (update: Partial<TControls>) => void;
|
|
@ -0,0 +1,218 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller';
|
||||
import {
|
||||
FieldDefinitionConfig,
|
||||
NamedFieldDefinitionConfig,
|
||||
WiredStreamGetResponse,
|
||||
} from '@kbn/streams-schema';
|
||||
import { isEqual, omit } from 'lodash';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useStreamsAppFetch } from '../../../hooks/use_streams_app_fetch';
|
||||
import { useKibana } from '../../../hooks/use_kibana';
|
||||
import { MappedSchemaField, SchemaField, isSchemaFieldTyped } from '../types';
|
||||
|
||||
export const useSchemaFields = ({
|
||||
definition,
|
||||
refreshDefinition,
|
||||
}: {
|
||||
definition: WiredStreamGetResponse;
|
||||
refreshDefinition: () => void;
|
||||
}) => {
|
||||
const {
|
||||
dependencies: {
|
||||
start: {
|
||||
streams: { streamsRepositoryClient },
|
||||
},
|
||||
},
|
||||
core: {
|
||||
notifications: { toasts },
|
||||
},
|
||||
} = useKibana();
|
||||
|
||||
const abortController = useAbortController();
|
||||
|
||||
const {
|
||||
value: unmappedFieldsValue,
|
||||
loading: isLoadingUnmappedFields,
|
||||
refresh: refreshUnmappedFields,
|
||||
} = useStreamsAppFetch(
|
||||
({ signal }) => {
|
||||
return streamsRepositoryClient.fetch('GET /api/streams/{id}/schema/unmapped_fields', {
|
||||
signal,
|
||||
params: {
|
||||
path: {
|
||||
id: definition.stream.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[definition.stream.name, streamsRepositoryClient]
|
||||
);
|
||||
|
||||
const fields = useMemo(() => {
|
||||
const inheritedFields: SchemaField[] = Object.entries(definition.inherited_fields).map(
|
||||
([name, field]) => ({
|
||||
name,
|
||||
type: field.type,
|
||||
format: field.format,
|
||||
parent: field.from,
|
||||
status: 'inherited',
|
||||
})
|
||||
);
|
||||
|
||||
const mappedFields: SchemaField[] = Object.entries(definition.stream.ingest.wired.fields).map(
|
||||
([name, field]) => ({
|
||||
name,
|
||||
type: field.type,
|
||||
format: field.format,
|
||||
parent: definition.stream.name,
|
||||
status: 'mapped',
|
||||
})
|
||||
);
|
||||
|
||||
const unmappedFields: SchemaField[] =
|
||||
unmappedFieldsValue?.unmappedFields.map((field) => ({
|
||||
name: field,
|
||||
parent: definition.stream.name,
|
||||
status: 'unmapped',
|
||||
})) ?? [];
|
||||
|
||||
return [...inheritedFields, ...mappedFields, ...unmappedFields];
|
||||
}, [definition, unmappedFieldsValue]);
|
||||
|
||||
const refreshFields = useCallback(() => {
|
||||
refreshDefinition();
|
||||
refreshUnmappedFields();
|
||||
}, [refreshDefinition, refreshUnmappedFields]);
|
||||
|
||||
const updateField = useCallback(
|
||||
async (field: SchemaField) => {
|
||||
try {
|
||||
if (!isSchemaFieldTyped(field)) {
|
||||
throw new Error('The field is not complete or fully mapped.');
|
||||
}
|
||||
|
||||
const nextFieldDefinitionConfig = convertToFieldDefinitionConfig(field);
|
||||
const persistedFieldDefinitionConfig = definition.stream.ingest.wired.fields[field.name];
|
||||
|
||||
if (!hasChanges(persistedFieldDefinitionConfig, nextFieldDefinitionConfig)) {
|
||||
throw new Error('The field is not different, hence updating is not necessary.');
|
||||
}
|
||||
|
||||
await streamsRepositoryClient.fetch(`PUT /api/streams/{id}/_ingest`, {
|
||||
signal: abortController.signal,
|
||||
params: {
|
||||
path: {
|
||||
id: definition.stream.name,
|
||||
},
|
||||
body: {
|
||||
ingest: {
|
||||
...definition.stream.ingest,
|
||||
wired: {
|
||||
fields: {
|
||||
...definition.stream.ingest.wired.fields,
|
||||
[field.name]: nextFieldDefinitionConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toasts.addSuccess(
|
||||
i18n.translate('xpack.streams.streamDetailSchemaEditorEditSuccessToast', {
|
||||
defaultMessage: '{field} was successfully edited',
|
||||
values: { field: field.name },
|
||||
})
|
||||
);
|
||||
|
||||
refreshFields();
|
||||
} catch (error) {
|
||||
toasts.addError(error, {
|
||||
title: i18n.translate('xpack.streams.streamDetailSchemaEditorEditErrorToast', {
|
||||
defaultMessage: 'Something went wrong editing the {field} field',
|
||||
values: { field: field.name },
|
||||
}),
|
||||
toastMessage: error.message,
|
||||
toastLifeTimeMs: 5000,
|
||||
});
|
||||
}
|
||||
},
|
||||
[abortController.signal, definition, refreshFields, streamsRepositoryClient, toasts]
|
||||
);
|
||||
|
||||
const unmapField = useCallback(
|
||||
async (fieldName: SchemaField['name']) => {
|
||||
try {
|
||||
const persistedFieldDefinitionConfig = definition.stream.ingest.wired.fields[fieldName];
|
||||
|
||||
if (!persistedFieldDefinitionConfig) {
|
||||
throw new Error('The field is not mapped, hence it cannot be unmapped.');
|
||||
}
|
||||
|
||||
await streamsRepositoryClient.fetch(`PUT /api/streams/{id}/_ingest`, {
|
||||
signal: abortController.signal,
|
||||
params: {
|
||||
path: {
|
||||
id: definition.stream.name,
|
||||
},
|
||||
body: {
|
||||
ingest: {
|
||||
...definition.stream.ingest,
|
||||
wired: {
|
||||
fields: omit(definition.stream.ingest.wired.fields, fieldName),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toasts.addSuccess(
|
||||
i18n.translate('xpack.streams.streamDetailSchemaEditorUnmapSuccessToast', {
|
||||
defaultMessage: '{field} was successfully unmapped',
|
||||
values: { field: fieldName },
|
||||
})
|
||||
);
|
||||
|
||||
refreshFields();
|
||||
} catch (error) {
|
||||
toasts.addError(error, {
|
||||
title: i18n.translate('xpack.streams.streamDetailSchemaEditorUnmapErrorToast', {
|
||||
defaultMessage: 'Something went wrong unmapping the {field} field',
|
||||
values: { field: fieldName },
|
||||
}),
|
||||
toastMessage: error.message,
|
||||
toastLifeTimeMs: 5000,
|
||||
});
|
||||
}
|
||||
},
|
||||
[abortController.signal, definition, refreshFields, streamsRepositoryClient, toasts]
|
||||
);
|
||||
|
||||
return {
|
||||
fields,
|
||||
isLoadingUnmappedFields,
|
||||
refreshFields,
|
||||
unmapField,
|
||||
updateField,
|
||||
};
|
||||
};
|
||||
|
||||
const convertToFieldDefinitionConfig = (field: MappedSchemaField): FieldDefinitionConfig => ({
|
||||
type: field.type,
|
||||
...(field.format && field.type === 'date' ? { format: field.format } : {}),
|
||||
});
|
||||
|
||||
const hasChanges = (
|
||||
field: Partial<NamedFieldDefinitionConfig>,
|
||||
fieldUpdate: Partial<NamedFieldDefinitionConfig>
|
||||
) => {
|
||||
return !isEqual(field, fieldUpdate);
|
||||
};
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFlexGroup, EuiPortal, EuiProgress } from '@elastic/eui';
|
||||
import { useControls } from './hooks/use_controls';
|
||||
import { SchemaEditorProps } from './types';
|
||||
import { SchemaEditorContextProvider } from './schema_editor_context';
|
||||
import { Controls } from './schema_editor_controls';
|
||||
import { FieldsTable } from './schema_editor_table';
|
||||
|
||||
export function SchemaEditor({
|
||||
fields,
|
||||
isLoading,
|
||||
onFieldUnmap,
|
||||
onFieldUpdate,
|
||||
onRefreshData,
|
||||
stream,
|
||||
withControls = false,
|
||||
withFieldSimulation = false,
|
||||
withTableActions = false,
|
||||
}: SchemaEditorProps) {
|
||||
const [controls, updateControls] = useControls();
|
||||
|
||||
return (
|
||||
<SchemaEditorContextProvider
|
||||
fields={fields}
|
||||
isLoading={isLoading}
|
||||
onFieldUnmap={onFieldUnmap}
|
||||
onFieldUpdate={onFieldUpdate}
|
||||
stream={stream}
|
||||
withControls={withControls}
|
||||
withFieldSimulation={withFieldSimulation}
|
||||
withTableActions={withTableActions}
|
||||
>
|
||||
<EuiFlexGroup direction="column" gutterSize="m">
|
||||
{isLoading ? (
|
||||
<EuiPortal>
|
||||
<EuiProgress size="xs" color="accent" position="fixed" />
|
||||
</EuiPortal>
|
||||
) : null}
|
||||
{withControls && (
|
||||
<Controls controls={controls} onChange={updateControls} onRefreshData={onRefreshData} />
|
||||
)}
|
||||
<FieldsTable
|
||||
fields={fields}
|
||||
controls={controls}
|
||||
stream={stream}
|
||||
withTableActions={withTableActions}
|
||||
/>
|
||||
</EuiFlexGroup>
|
||||
</SchemaEditorContextProvider>
|
||||
);
|
||||
}
|
|
@ -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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import createContainer from 'constate';
|
||||
|
||||
import { SchemaEditorProps } from './types';
|
||||
|
||||
const useSchemaEditor = (props: SchemaEditorProps) => props;
|
||||
|
||||
export const [SchemaEditorContextProvider, useSchemaEditorContext] =
|
||||
createContainer(useSchemaEditor);
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFlexItem, EuiFlexGroup, EuiSearchBar, EuiButton } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FieldStatusFilterGroup } from './filters/status_filter_group';
|
||||
import { FieldTypeFilterGroup } from './filters/type_filter_group';
|
||||
import { TControls } from './hooks/use_controls';
|
||||
import { SchemaEditorProps } from './types';
|
||||
|
||||
interface ControlsProps {
|
||||
controls: TControls;
|
||||
onChange: (nextControls: Partial<TControls>) => void;
|
||||
onRefreshData: SchemaEditorProps['onRefreshData'];
|
||||
}
|
||||
|
||||
export function Controls({ controls, onChange, onRefreshData }: ControlsProps) {
|
||||
return (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiSearchBar
|
||||
query={controls.query}
|
||||
onChange={(nextQuery) => onChange({ query: nextQuery.query ?? undefined })}
|
||||
box={{
|
||||
incremental: true,
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<FieldTypeFilterGroup onChange={onChange} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<FieldStatusFilterGroup onChange={onChange} />
|
||||
</EuiFlexItem>
|
||||
{onRefreshData && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
data-test-subj="streamsAppContentRefreshButton"
|
||||
iconType="refresh"
|
||||
onClick={onRefreshData}
|
||||
>
|
||||
{i18n.translate('xpack.streams.schemaEditor.refreshDataButtonLabel', {
|
||||
defaultMessage: 'Refresh',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
EuiDataGridColumnSortingConfig,
|
||||
EuiSearchBar,
|
||||
EuiScreenReaderOnly,
|
||||
EuiDataGrid,
|
||||
EuiDataGridCellProps,
|
||||
EuiDataGridControlColumn,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { WiredStreamDefinition } from '@kbn/streams-schema';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { TABLE_COLUMNS, EMPTY_CONTENT } from './constants';
|
||||
import { FieldActionsCell } from './field_actions';
|
||||
import { FieldParent } from './field_parent';
|
||||
import { FieldStatusBadge } from './field_status';
|
||||
import { TControls } from './hooks/use_controls';
|
||||
import { SchemaField } from './types';
|
||||
import { FieldType } from './field_type';
|
||||
|
||||
export function FieldsTable({
|
||||
fields,
|
||||
controls,
|
||||
stream,
|
||||
withTableActions,
|
||||
}: {
|
||||
fields: SchemaField[];
|
||||
controls: TControls;
|
||||
stream: WiredStreamDefinition;
|
||||
withTableActions: boolean;
|
||||
}) {
|
||||
// Column visibility
|
||||
const [visibleColumns, setVisibleColumns] = useState(Object.keys(TABLE_COLUMNS));
|
||||
// Column sorting
|
||||
const [sortingColumns, setSortingColumns] = useState<EuiDataGridColumnSortingConfig[]>([]);
|
||||
|
||||
const filteredFields = useMemo(
|
||||
() => filterFieldsByControls(fields, controls),
|
||||
[fields, controls]
|
||||
);
|
||||
|
||||
const trailingColumns = useMemo(() => {
|
||||
if (!withTableActions) return undefined;
|
||||
|
||||
return [createFieldActionsCellRenderer(filteredFields)];
|
||||
}, [withTableActions, filteredFields]);
|
||||
|
||||
const RenderCellValue = useMemo(
|
||||
() => createCellRenderer(filteredFields, stream),
|
||||
[filteredFields, stream]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiDataGrid
|
||||
aria-label={i18n.translate(
|
||||
'xpack.streams.streamDetailSchemaEditor.fieldsTable.actionsTitle',
|
||||
{ defaultMessage: 'Preview' }
|
||||
)}
|
||||
columns={Object.entries(TABLE_COLUMNS).map(([columnId, value]) => ({
|
||||
id: columnId,
|
||||
...value,
|
||||
}))}
|
||||
columnVisibility={{
|
||||
visibleColumns,
|
||||
setVisibleColumns,
|
||||
canDragAndDropColumns: false,
|
||||
}}
|
||||
sorting={{ columns: sortingColumns, onSort: setSortingColumns }}
|
||||
toolbarVisibility={true}
|
||||
rowCount={filteredFields.length}
|
||||
renderCellValue={RenderCellValue}
|
||||
trailingControlColumns={trailingColumns}
|
||||
gridStyle={{
|
||||
border: 'none',
|
||||
rowHover: 'none',
|
||||
header: 'underline',
|
||||
}}
|
||||
inMemory={{ level: 'sorting' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const createCellRenderer =
|
||||
(fields: SchemaField[], stream: WiredStreamDefinition): EuiDataGridCellProps['renderCellValue'] =>
|
||||
({ rowIndex, columnId }) => {
|
||||
const field = fields[rowIndex];
|
||||
if (!field) return null;
|
||||
const { parent, status } = field;
|
||||
|
||||
if (columnId === 'type') {
|
||||
if (!field.type) return EMPTY_CONTENT;
|
||||
return <FieldType type={field.type} />;
|
||||
}
|
||||
|
||||
if (columnId === 'parent') {
|
||||
return <FieldParent parent={parent} linkEnabled={field.parent !== stream.name} />;
|
||||
}
|
||||
|
||||
if (columnId === 'status') {
|
||||
return <FieldStatusBadge status={status} />;
|
||||
}
|
||||
|
||||
return field[columnId as keyof SchemaField] || EMPTY_CONTENT;
|
||||
};
|
||||
|
||||
const createFieldActionsCellRenderer = (fields: SchemaField[]): EuiDataGridControlColumn => ({
|
||||
id: 'field-actions',
|
||||
width: 40,
|
||||
headerCellRender: () => (
|
||||
<EuiScreenReaderOnly>
|
||||
<span>
|
||||
{i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableActionsTitle', {
|
||||
defaultMessage: 'Field actions',
|
||||
})}
|
||||
</span>
|
||||
</EuiScreenReaderOnly>
|
||||
),
|
||||
rowCellRender: ({ rowIndex }) => {
|
||||
const field = fields[rowIndex];
|
||||
|
||||
if (!field) return null;
|
||||
|
||||
return <FieldActionsCell field={field} />;
|
||||
},
|
||||
});
|
||||
|
||||
const filterFieldsByControls = (fields: SchemaField[], controls: TControls) => {
|
||||
if (!controls.query && isEmpty(controls.type) && isEmpty(controls.status)) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
const matchingQueryFields = EuiSearchBar.Query.execute(controls.query, fields, {
|
||||
defaultFields: ['name', 'type'],
|
||||
});
|
||||
|
||||
const filteredByGroupsFields = matchingQueryFields.filter((field) => {
|
||||
return (
|
||||
(isEmpty(controls.type) || (field.type && controls.type.includes(field.type))) && // Filter by applied type
|
||||
(isEmpty(controls.status) || controls.status.includes(field.status)) // Filter by applied status
|
||||
);
|
||||
});
|
||||
|
||||
return filteredByGroupsFields;
|
||||
};
|
|
@ -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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FieldDefinitionConfig, WiredStreamDefinition } from '@kbn/streams-schema';
|
||||
|
||||
export type SchemaFieldStatus = 'inherited' | 'mapped' | 'unmapped';
|
||||
export type SchemaFieldType = FieldDefinitionConfig['type'];
|
||||
|
||||
export interface BaseSchemaField extends Omit<FieldDefinitionConfig, 'type'> {
|
||||
name: string;
|
||||
parent: string;
|
||||
}
|
||||
|
||||
export interface MappedSchemaField extends BaseSchemaField {
|
||||
status: 'inherited' | 'mapped';
|
||||
type: SchemaFieldType;
|
||||
}
|
||||
|
||||
export interface UnmappedSchemaField extends BaseSchemaField {
|
||||
status: 'unmapped';
|
||||
type?: SchemaFieldType | undefined;
|
||||
}
|
||||
|
||||
export type SchemaField = MappedSchemaField | UnmappedSchemaField;
|
||||
|
||||
export interface SchemaEditorProps {
|
||||
fields: SchemaField[];
|
||||
isLoading?: boolean;
|
||||
onFieldUnmap: (fieldName: SchemaField['name']) => void;
|
||||
onFieldUpdate: (field: SchemaField) => void;
|
||||
onRefreshData?: () => void;
|
||||
stream: WiredStreamDefinition;
|
||||
withControls?: boolean;
|
||||
withFieldSimulation?: boolean;
|
||||
withTableActions?: boolean;
|
||||
}
|
||||
|
||||
export const isSchemaFieldTyped = (field: SchemaField): field is MappedSchemaField => {
|
||||
return !!field && !!field.name && !!field.type;
|
||||
};
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { EuiConfirmModal } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
import { SchemaEditorProps, SchemaField } from './types';
|
||||
|
||||
export const UnpromoteFieldModal = ({
|
||||
field,
|
||||
onClose,
|
||||
onFieldUnmap,
|
||||
}: {
|
||||
field: SchemaField;
|
||||
onClose: () => void;
|
||||
onFieldUnmap: SchemaEditorProps['onFieldUnmap'];
|
||||
}) => {
|
||||
const [{ loading }, unmapField] = useAsyncFn(async () => {
|
||||
await onFieldUnmap(field.name);
|
||||
if (onClose) onClose();
|
||||
}, [field, onClose, onFieldUnmap]);
|
||||
|
||||
return (
|
||||
<EuiConfirmModal
|
||||
isLoading={loading}
|
||||
title={field.name}
|
||||
onCancel={onClose}
|
||||
onConfirm={unmapField}
|
||||
cancelButtonText={i18n.translate(
|
||||
'xpack.streams.unpromoteFieldModal.unpromoteFieldButtonCancelLabel',
|
||||
{ defaultMessage: 'Cancel' }
|
||||
)}
|
||||
confirmButtonText={i18n.translate(
|
||||
'xpack.streams.unpromoteFieldModal.unpromoteFieldButtonLabel',
|
||||
{ defaultMessage: 'Unmap field' }
|
||||
)}
|
||||
buttonColor="danger"
|
||||
>
|
||||
{i18n.translate('xpack.streams.unpromoteFieldModal.unpromoteFieldWarning', {
|
||||
defaultMessage: 'Are you sure you want to unmap this field from template mappings?',
|
||||
})}
|
||||
</EuiConfirmModal>
|
||||
);
|
||||
};
|
|
@ -1,395 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiContextMenu,
|
||||
EuiDataGrid,
|
||||
EuiPopover,
|
||||
EuiSearchBar,
|
||||
useGeneratedHtmlId,
|
||||
} from '@elastic/eui';
|
||||
import type {
|
||||
EuiContextMenuPanelDescriptor,
|
||||
EuiContextMenuPanelItemDescriptor,
|
||||
EuiDataGridColumnSortingConfig,
|
||||
EuiDataGridProps,
|
||||
Query,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import useToggle from 'react-use/lib/useToggle';
|
||||
import { isRootStreamDefinition, WiredStreamGetResponse } from '@kbn/streams-schema';
|
||||
import { FieldType } from './field_type';
|
||||
import { FieldStatusBadge } from './field_status';
|
||||
import { FieldEntry, SchemaEditorEditingState } from './hooks/use_editing_state';
|
||||
import { SchemaEditorUnpromotingState } from './hooks/use_unpromoting_state';
|
||||
import { FieldParent } from './field_parent';
|
||||
import { SchemaEditorQueryAndFiltersState } from './hooks/use_query_and_filters';
|
||||
|
||||
interface FieldsTableContainerProps {
|
||||
definition: WiredStreamGetResponse;
|
||||
unmappedFieldsResult?: string[];
|
||||
isLoadingUnmappedFields: boolean;
|
||||
query?: Query;
|
||||
editingState: SchemaEditorEditingState;
|
||||
unpromotingState: SchemaEditorUnpromotingState;
|
||||
queryAndFiltersState: SchemaEditorQueryAndFiltersState;
|
||||
}
|
||||
|
||||
const COLUMNS = {
|
||||
name: {
|
||||
display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTablenameHeader', {
|
||||
defaultMessage: 'Field',
|
||||
}),
|
||||
},
|
||||
type: {
|
||||
display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTabletypeHeader', {
|
||||
defaultMessage: 'Type',
|
||||
}),
|
||||
},
|
||||
format: {
|
||||
display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableformatHeader', {
|
||||
defaultMessage: 'Format',
|
||||
}),
|
||||
},
|
||||
parent: {
|
||||
display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTableFieldParentHeader', {
|
||||
defaultMessage: 'Field Parent (Stream)',
|
||||
}),
|
||||
},
|
||||
status: {
|
||||
display: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldsTablestatusHeader', {
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const EMPTY_CONTENT = '-----';
|
||||
|
||||
export const FieldsTableContainer = ({
|
||||
definition,
|
||||
unmappedFieldsResult,
|
||||
query,
|
||||
editingState,
|
||||
unpromotingState,
|
||||
queryAndFiltersState,
|
||||
}: FieldsTableContainerProps) => {
|
||||
const inheritedFields = useMemo(() => {
|
||||
return Object.entries(definition.inherited_fields).map(([name, field]) => ({
|
||||
name,
|
||||
type: field.type,
|
||||
format: field.format,
|
||||
parent: field.from,
|
||||
status: 'inherited' as const,
|
||||
}));
|
||||
}, [definition]);
|
||||
|
||||
const filteredInheritedFields = useMemo(() => {
|
||||
if (!query) return inheritedFields;
|
||||
return EuiSearchBar.Query.execute(query, inheritedFields, {
|
||||
defaultFields: ['name', 'type'],
|
||||
});
|
||||
}, [inheritedFields, query]);
|
||||
|
||||
const mappedFields = useMemo(() => {
|
||||
return Object.entries(definition.stream.ingest.wired.fields).map(([name, field]) => ({
|
||||
name,
|
||||
type: field.type,
|
||||
format: field.format,
|
||||
parent: definition.stream.name,
|
||||
status: 'mapped' as const,
|
||||
}));
|
||||
return [];
|
||||
}, [definition]);
|
||||
|
||||
const filteredMappedFields = useMemo(() => {
|
||||
if (!query) return mappedFields;
|
||||
return EuiSearchBar.Query.execute(query, mappedFields, {
|
||||
defaultFields: ['name', 'type'],
|
||||
});
|
||||
}, [mappedFields, query]);
|
||||
|
||||
const unmappedFields = useMemo(() => {
|
||||
return unmappedFieldsResult
|
||||
? unmappedFieldsResult.map((field) => ({
|
||||
name: field,
|
||||
parent: definition.stream.name,
|
||||
status: 'unmapped' as const,
|
||||
}))
|
||||
: [];
|
||||
}, [definition.stream.name, unmappedFieldsResult]);
|
||||
|
||||
const filteredUnmappedFields = useMemo(() => {
|
||||
if (!unmappedFieldsResult) return [];
|
||||
if (!query) return unmappedFields;
|
||||
return EuiSearchBar.Query.execute(query, unmappedFields, {
|
||||
defaultFields: ['name'],
|
||||
});
|
||||
}, [unmappedFieldsResult, query, unmappedFields]);
|
||||
|
||||
const allFilteredFields = useMemo(() => {
|
||||
return [...filteredInheritedFields, ...filteredMappedFields, ...filteredUnmappedFields];
|
||||
}, [filteredInheritedFields, filteredMappedFields, filteredUnmappedFields]);
|
||||
|
||||
const filteredFieldsWithFilterGroupsApplied = useMemo(() => {
|
||||
const filterGroups = queryAndFiltersState.filterGroups;
|
||||
let fieldsWithFilterGroupsApplied = allFilteredFields;
|
||||
|
||||
if (filterGroups.type && filterGroups.type.length > 0) {
|
||||
fieldsWithFilterGroupsApplied = fieldsWithFilterGroupsApplied.filter(
|
||||
(field) => 'type' in field && filterGroups.type.includes(field.type)
|
||||
);
|
||||
}
|
||||
|
||||
if (filterGroups.status && filterGroups.status.length > 0) {
|
||||
fieldsWithFilterGroupsApplied = fieldsWithFilterGroupsApplied.filter(
|
||||
(field) => 'status' in field && filterGroups.status.includes(field.status)
|
||||
);
|
||||
}
|
||||
|
||||
return fieldsWithFilterGroupsApplied;
|
||||
}, [allFilteredFields, queryAndFiltersState.filterGroups]);
|
||||
|
||||
return (
|
||||
<FieldsTable
|
||||
fields={filteredFieldsWithFilterGroupsApplied}
|
||||
editingState={editingState}
|
||||
unpromotingState={unpromotingState}
|
||||
definition={definition}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface FieldsTableProps {
|
||||
definition: WiredStreamGetResponse;
|
||||
fields: FieldEntry[];
|
||||
editingState: SchemaEditorEditingState;
|
||||
unpromotingState: SchemaEditorUnpromotingState;
|
||||
}
|
||||
|
||||
const FieldsTable = ({ definition, fields, editingState, unpromotingState }: FieldsTableProps) => {
|
||||
// Column visibility
|
||||
const [visibleColumns, setVisibleColumns] = useState(Object.keys(COLUMNS));
|
||||
|
||||
// Column sorting
|
||||
const [sortingColumns, setSortingColumns] = useState<EuiDataGridColumnSortingConfig[]>([]);
|
||||
|
||||
const trailingColumns = useMemo(() => {
|
||||
return !isRootStreamDefinition(definition.stream)
|
||||
? ([
|
||||
{
|
||||
id: 'actions',
|
||||
width: 40,
|
||||
headerCellRender: () => null,
|
||||
rowCellRender: ({ rowIndex }) => {
|
||||
const field = fields[rowIndex];
|
||||
|
||||
if (!field) return null;
|
||||
|
||||
let actions: ActionsCellActionsDescriptor[] = [];
|
||||
|
||||
switch (field.status) {
|
||||
case 'mapped':
|
||||
actions = [
|
||||
{
|
||||
name: i18n.translate('xpack.streams.actions.viewFieldLabel', {
|
||||
defaultMessage: 'View field',
|
||||
}),
|
||||
disabled: editingState.isSaving,
|
||||
onClick: (fieldEntry: FieldEntry) => {
|
||||
editingState.selectField(fieldEntry, false);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.streams.actions.editFieldLabel', {
|
||||
defaultMessage: 'Edit field',
|
||||
}),
|
||||
disabled: editingState.isSaving,
|
||||
onClick: (fieldEntry: FieldEntry) => {
|
||||
editingState.selectField(fieldEntry, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.streams.actions.unpromoteFieldLabel', {
|
||||
defaultMessage: 'Unmap field',
|
||||
}),
|
||||
disabled: unpromotingState.isUnpromotingField,
|
||||
onClick: (fieldEntry: FieldEntry) => {
|
||||
unpromotingState.setSelectedField(fieldEntry.name);
|
||||
},
|
||||
},
|
||||
];
|
||||
break;
|
||||
case 'unmapped':
|
||||
actions = [
|
||||
{
|
||||
name: i18n.translate('xpack.streams.actions.viewFieldLabel', {
|
||||
defaultMessage: 'View field',
|
||||
}),
|
||||
disabled: editingState.isSaving,
|
||||
onClick: (fieldEntry: FieldEntry) => {
|
||||
editingState.selectField(fieldEntry, false);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.translate('xpack.streams.actions.mapFieldLabel', {
|
||||
defaultMessage: 'Map field',
|
||||
}),
|
||||
disabled: editingState.isSaving,
|
||||
onClick: (fieldEntry: FieldEntry) => {
|
||||
editingState.selectField(fieldEntry, true);
|
||||
},
|
||||
},
|
||||
];
|
||||
break;
|
||||
case 'inherited':
|
||||
actions = [
|
||||
{
|
||||
name: i18n.translate('xpack.streams.actions.viewFieldLabel', {
|
||||
defaultMessage: 'View field',
|
||||
}),
|
||||
disabled: editingState.isSaving,
|
||||
onClick: (fieldEntry: FieldEntry) => {
|
||||
editingState.selectField(fieldEntry, false);
|
||||
},
|
||||
},
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionsCell
|
||||
panels={[
|
||||
{
|
||||
id: 0,
|
||||
title: i18n.translate(
|
||||
'xpack.streams.streamDetailSchemaEditorFieldsTableActionsTitle',
|
||||
{
|
||||
defaultMessage: 'Actions',
|
||||
}
|
||||
),
|
||||
items: actions.map((action) => ({
|
||||
name: action.name,
|
||||
icon: action.icon,
|
||||
onClick: (event) => {
|
||||
action.onClick(field);
|
||||
},
|
||||
})),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
] as EuiDataGridProps['trailingControlColumns'])
|
||||
: undefined;
|
||||
}, [definition, editingState, fields, unpromotingState]);
|
||||
|
||||
return (
|
||||
<EuiDataGrid
|
||||
aria-label={i18n.translate(
|
||||
'xpack.streams.streamDetailSchemaEditor.fieldsTable.actionsTitle',
|
||||
{
|
||||
defaultMessage: 'Preview',
|
||||
}
|
||||
)}
|
||||
columns={Object.entries(COLUMNS).map(([columnId, value]) => ({
|
||||
id: columnId,
|
||||
...value,
|
||||
}))}
|
||||
columnVisibility={{
|
||||
visibleColumns,
|
||||
setVisibleColumns,
|
||||
canDragAndDropColumns: false,
|
||||
}}
|
||||
sorting={{ columns: sortingColumns, onSort: setSortingColumns }}
|
||||
toolbarVisibility={true}
|
||||
rowCount={fields.length}
|
||||
renderCellValue={({ rowIndex, columnId }) => {
|
||||
const field = fields[rowIndex];
|
||||
if (!field) return null;
|
||||
|
||||
if (columnId === 'type') {
|
||||
const fieldType = field.type;
|
||||
if (!fieldType) return EMPTY_CONTENT;
|
||||
return <FieldType type={fieldType} />;
|
||||
} else if (columnId === 'parent') {
|
||||
return (
|
||||
<FieldParent
|
||||
parent={field.parent}
|
||||
linkEnabled={field.parent !== definition.stream.name}
|
||||
/>
|
||||
);
|
||||
} else if (columnId === 'status') {
|
||||
return <FieldStatusBadge status={field.status} />;
|
||||
} else {
|
||||
return field[columnId as keyof FieldEntry] || EMPTY_CONTENT;
|
||||
}
|
||||
}}
|
||||
trailingControlColumns={trailingColumns}
|
||||
gridStyle={{
|
||||
border: 'none',
|
||||
rowHover: 'none',
|
||||
header: 'underline',
|
||||
}}
|
||||
inMemory={{ level: 'sorting' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ActionsCell = ({ panels }: { panels: EuiContextMenuPanelDescriptor[] }) => {
|
||||
const contextMenuPopoverId = useGeneratedHtmlId({
|
||||
prefix: 'fieldsTableContextMenuPopover',
|
||||
});
|
||||
|
||||
const [popoverIsOpen, togglePopoverIsOpen] = useToggle(false);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
id={contextMenuPopoverId}
|
||||
button={
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.translate(
|
||||
'xpack.streams.streamDetailSchemaEditorFieldsTableActionsTriggerButton',
|
||||
{
|
||||
defaultMessage: 'Open actions menu',
|
||||
}
|
||||
)}
|
||||
data-test-subj="streamsAppActionsButton"
|
||||
iconType="boxesVertical"
|
||||
onClick={() => {
|
||||
togglePopoverIsOpen();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
isOpen={popoverIsOpen}
|
||||
closePopover={() => togglePopoverIsOpen(false)}
|
||||
>
|
||||
<EuiContextMenu
|
||||
initialPanelId={0}
|
||||
panels={panels.map((panel) => ({
|
||||
...panel,
|
||||
items: panel.items?.map((item) => ({
|
||||
name: item.name,
|
||||
icon: item.icon,
|
||||
onClick: (event) => {
|
||||
if (item.onClick) {
|
||||
item.onClick(event as any);
|
||||
}
|
||||
togglePopoverIsOpen(false);
|
||||
},
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
||||
|
||||
export type ActionsCellActionsDescriptor = Omit<EuiContextMenuPanelItemDescriptor, 'onClick'> & {
|
||||
onClick: (field: FieldEntry) => void;
|
||||
};
|
|
@ -1,51 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiSelect } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { SchemaEditorEditingState } from '../hooks/use_editing_state';
|
||||
|
||||
type FieldFormTypeProps = Pick<SchemaEditorEditingState, 'nextFieldType' | 'setNextFieldType'> & {
|
||||
isLoadingRecommendation: boolean;
|
||||
recommendation?: string;
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS = {
|
||||
long: 'Long',
|
||||
double: 'Double',
|
||||
keyword: 'Keyword',
|
||||
match_only_text: 'Text (match_only_text)',
|
||||
boolean: 'Boolean',
|
||||
ip: 'IP',
|
||||
date: 'Date',
|
||||
} as const;
|
||||
|
||||
type FieldTypeOption = keyof typeof TYPE_OPTIONS;
|
||||
|
||||
export const FieldFormType = ({
|
||||
nextFieldType: value,
|
||||
setNextFieldType: onChange,
|
||||
isLoadingRecommendation,
|
||||
recommendation,
|
||||
}: FieldFormTypeProps) => {
|
||||
return (
|
||||
<EuiSelect
|
||||
disabled={!value && isLoadingRecommendation}
|
||||
isLoading={isLoadingRecommendation}
|
||||
data-test-subj="streamsAppFieldFormTypeSelect"
|
||||
hasNoInitialSelection={!value}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value as FieldTypeOption);
|
||||
}}
|
||||
value={value}
|
||||
options={Object.entries(TYPE_OPTIONS).map(([optionKey, optionValue]) => ({
|
||||
text: optionValue,
|
||||
value: optionKey,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,84 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import React, { useEffect } from 'react';
|
||||
import { EMPTY_CONTENT } from '../fields_table';
|
||||
import { EcsRecommendation } from './ecs_recommendation';
|
||||
import { FieldFormType } from './field_form_type';
|
||||
import { FieldEntry, SchemaEditorEditingState } from '../hooks/use_editing_state';
|
||||
import { FieldType } from '../field_type';
|
||||
import { useKibana } from '../../../hooks/use_kibana';
|
||||
import { FIELD_TYPE_MAP } from '../configuration_maps';
|
||||
|
||||
export const FieldFormTypeWrapper = ({
|
||||
isEditing,
|
||||
nextFieldType,
|
||||
setNextFieldType,
|
||||
selectedFieldType,
|
||||
selectedFieldName,
|
||||
}: {
|
||||
isEditing: boolean;
|
||||
nextFieldType: SchemaEditorEditingState['nextFieldType'];
|
||||
setNextFieldType: SchemaEditorEditingState['setNextFieldType'];
|
||||
selectedFieldType: FieldEntry['type'];
|
||||
selectedFieldName: FieldEntry['name'];
|
||||
}) => {
|
||||
const {
|
||||
dependencies: {
|
||||
start: {
|
||||
fieldsMetadata: { useFieldsMetadata },
|
||||
},
|
||||
},
|
||||
} = useKibana();
|
||||
|
||||
const { fieldsMetadata, loading } = useFieldsMetadata(
|
||||
{
|
||||
attributes: ['type'],
|
||||
fieldNames: [selectedFieldName],
|
||||
},
|
||||
[selectedFieldName]
|
||||
);
|
||||
|
||||
// Propagate recommendation to state if a type is not already set
|
||||
useEffect(() => {
|
||||
const recommendation = fieldsMetadata?.[selectedFieldName]?.type;
|
||||
if (
|
||||
!loading &&
|
||||
recommendation !== undefined &&
|
||||
// Supported type
|
||||
recommendation in FIELD_TYPE_MAP &&
|
||||
!nextFieldType
|
||||
) {
|
||||
setNextFieldType(recommendation as FieldEntry['type']);
|
||||
}
|
||||
}, [fieldsMetadata, loading, nextFieldType, selectedFieldName, setNextFieldType]);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
{isEditing ? (
|
||||
<FieldFormType
|
||||
nextFieldType={nextFieldType}
|
||||
setNextFieldType={setNextFieldType}
|
||||
isLoadingRecommendation={loading}
|
||||
recommendation={fieldsMetadata?.[selectedFieldName]?.type}
|
||||
/>
|
||||
) : selectedFieldType ? (
|
||||
<FieldType type={selectedFieldType} />
|
||||
) : (
|
||||
`${EMPTY_CONTENT}`
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EcsRecommendation
|
||||
isLoading={loading}
|
||||
recommendation={fieldsMetadata?.[selectedFieldName]?.type}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -1,215 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiHorizontalRule,
|
||||
EuiIconTip,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router';
|
||||
import { FieldParent } from '../field_parent';
|
||||
import { FieldStatusBadge } from '../field_status';
|
||||
import { FieldFormFormat, typeSupportsFormat } from './field_form_format';
|
||||
import { SchemaEditorFlyoutProps } from '.';
|
||||
import { FieldFormTypeWrapper } from './field_form_type_wrapper';
|
||||
|
||||
const EMPTY_CONTENT = '-----';
|
||||
|
||||
const title = i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryTitle', {
|
||||
defaultMessage: 'Field summary',
|
||||
});
|
||||
|
||||
const FIELD_SUMMARIES = {
|
||||
fieldStatus: {
|
||||
label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldNameHeader', {
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
},
|
||||
fieldType: {
|
||||
label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldTypeHeader', {
|
||||
defaultMessage: 'Type',
|
||||
}),
|
||||
},
|
||||
fieldFormat: {
|
||||
label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldFormatHeader', {
|
||||
defaultMessage: 'Format',
|
||||
}),
|
||||
},
|
||||
fieldParent: {
|
||||
label: i18n.translate('xpack.streams.streamDetailSchemaEditorFieldSummaryFieldParentHeader', {
|
||||
defaultMessage: 'Field Parent',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const FieldSummary = (props: SchemaEditorFlyoutProps) => {
|
||||
const {
|
||||
selectedField,
|
||||
isEditing,
|
||||
nextFieldType,
|
||||
setNextFieldType,
|
||||
nextFieldFormat,
|
||||
setNextFieldFormat,
|
||||
toggleIsEditing,
|
||||
} = props;
|
||||
|
||||
const router = useStreamsAppRouter();
|
||||
|
||||
if (!selectedField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiTitle size="xxs">
|
||||
<span>{title} </span>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{selectedField.status !== 'inherited' && !isEditing ? (
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="streamsAppFieldSummaryEditButton"
|
||||
size="s"
|
||||
color="primary"
|
||||
onClick={() => toggleIsEditing()}
|
||||
iconType="pencil"
|
||||
>
|
||||
{i18n.translate('xpack.streams.fieldSummary.editButtonLabel', {
|
||||
defaultMessage: 'Edit',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
) : selectedField.status === 'inherited' ? (
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="streamsAppFieldSummaryOpenInParentButton"
|
||||
size="s"
|
||||
color="primary"
|
||||
iconType="popout"
|
||||
href={router.link('/{key}/{tab}/{subtab}', {
|
||||
path: {
|
||||
key: selectedField.parent,
|
||||
tab: 'management',
|
||||
subtab: 'schemaEditor',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{i18n.translate('xpack.streams.fieldSummary.editInParentButtonLabel', {
|
||||
defaultMessage: 'Edit in parent stream',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiTitle size="xxs">
|
||||
<span>
|
||||
{FIELD_SUMMARIES.fieldStatus.label}{' '}
|
||||
<EuiIconTip
|
||||
type="iInCircle"
|
||||
color="subdued"
|
||||
content={i18n.translate('xpack.streams.fieldSummary.statusTooltip', {
|
||||
defaultMessage:
|
||||
'Indicates whether the field is actively mapped for use in the configuration or remains unmapped and inactive.',
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<FieldStatusBadge status={selectedField.status} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiTitle size="xxs">
|
||||
<span>{FIELD_SUMMARIES.fieldType.label}</span>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={2}>
|
||||
<FieldFormTypeWrapper
|
||||
isEditing={isEditing}
|
||||
nextFieldType={nextFieldType}
|
||||
setNextFieldType={setNextFieldType}
|
||||
selectedFieldType={selectedField.type}
|
||||
selectedFieldName={selectedField.name}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
|
||||
{typeSupportsFormat(nextFieldType) && (
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiTitle size="xxs">
|
||||
<span>{FIELD_SUMMARIES.fieldFormat.label}</span>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={2}>
|
||||
{isEditing ? (
|
||||
<FieldFormFormat
|
||||
nextFieldFormat={nextFieldFormat}
|
||||
setNextFieldFormat={setNextFieldFormat}
|
||||
nextFieldType={nextFieldType}
|
||||
/>
|
||||
) : (
|
||||
`${selectedField.format ?? EMPTY_CONTENT}`
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={1}>
|
||||
<EuiTitle size="xxs">
|
||||
<span>{FIELD_SUMMARIES.fieldParent.label}</span>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={2}>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<FieldParent parent={selectedField.parent} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -1,104 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutHeader,
|
||||
EuiFlyoutFooter,
|
||||
EuiTitle,
|
||||
EuiButton,
|
||||
} from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { WiredStreamGetResponse } from '@kbn/streams-schema';
|
||||
import { SchemaEditorEditingState } from '../hooks/use_editing_state';
|
||||
import { ChildrenAffectedCallout } from './children_affected_callout';
|
||||
import { SamplePreviewTable } from './sample_preview_table';
|
||||
import { FieldSummary } from './field_summary';
|
||||
|
||||
export type SchemaEditorFlyoutProps = {
|
||||
streamsRepositoryClient: StreamsRepositoryClient;
|
||||
definition: WiredStreamGetResponse;
|
||||
} & SchemaEditorEditingState;
|
||||
|
||||
export const SchemaEditorFlyout = (props: SchemaEditorFlyoutProps) => {
|
||||
const {
|
||||
definition,
|
||||
streamsRepositoryClient,
|
||||
selectedField,
|
||||
reset,
|
||||
nextFieldDefinition,
|
||||
isEditing,
|
||||
isSaving,
|
||||
saveChanges,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<EuiFlyout onClose={() => reset()} maxWidth="500px">
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiFlexGroup alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="m">
|
||||
<h2>{selectedField?.name}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutHeader>
|
||||
|
||||
<EuiFlyoutBody>
|
||||
<EuiFlexGroup direction="column">
|
||||
<FieldSummary {...props} />
|
||||
{isEditing && definition.stream.ingest.routing.length > 0 ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<ChildrenAffectedCallout childStreams={definition.stream.ingest.routing} />
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
<EuiFlexItem grow={false}>
|
||||
<SamplePreviewTable
|
||||
definition={definition}
|
||||
nextFieldDefinition={nextFieldDefinition}
|
||||
streamsRepositoryClient={streamsRepositoryClient}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutBody>
|
||||
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="streamsAppSchemaEditorFlyoutCloseButton"
|
||||
iconType="cross"
|
||||
onClick={() => reset()}
|
||||
flush="left"
|
||||
>
|
||||
{i18n.translate('xpack.streams.schemaEditorFlyout.closeButtonLabel', {
|
||||
defaultMessage: 'Cancel',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
data-test-subj="streamsAppSchemaEditorFieldSaveButton"
|
||||
isDisabled={isSaving || !saveChanges}
|
||||
onClick={() => saveChanges && saveChanges()}
|
||||
>
|
||||
{i18n.translate('xpack.streams.fieldForm.saveButtonLabel', {
|
||||
defaultMessage: 'Save changes',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
};
|
|
@ -1,185 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { NamedFieldDefinitionConfig, WiredStreamGetResponse } from '@kbn/streams-schema';
|
||||
import { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import useToggle from 'react-use/lib/useToggle';
|
||||
import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller';
|
||||
import { ToastsStart } from '@kbn/core-notifications-browser';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { omit } from 'lodash';
|
||||
import { FieldStatus } from '../configuration_maps';
|
||||
|
||||
export type SchemaEditorEditingState = ReturnType<typeof useEditingState>;
|
||||
|
||||
export interface FieldEntry {
|
||||
name: NamedFieldDefinitionConfig['name'];
|
||||
type?: NamedFieldDefinitionConfig['type'];
|
||||
format?: NamedFieldDefinitionConfig['format'];
|
||||
parent: string;
|
||||
status: FieldStatus;
|
||||
}
|
||||
|
||||
export type EditableFieldDefinition = FieldEntry;
|
||||
|
||||
export const useEditingState = ({
|
||||
streamsRepositoryClient,
|
||||
definition,
|
||||
refreshDefinition,
|
||||
refreshUnmappedFields,
|
||||
toastsService,
|
||||
}: {
|
||||
streamsRepositoryClient: StreamsRepositoryClient;
|
||||
definition: WiredStreamGetResponse;
|
||||
refreshDefinition: () => void;
|
||||
refreshUnmappedFields: () => void;
|
||||
toastsService: ToastsStart;
|
||||
}) => {
|
||||
const abortController = useAbortController();
|
||||
/* Whether the field is being edited, otherwise it's just displayed. */
|
||||
const [isEditing, toggleIsEditing] = useToggle(false);
|
||||
/* Whether changes are being persisted */
|
||||
const [isSaving, toggleIsSaving] = useToggle(false);
|
||||
/* Holds errors from saving changes */
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
|
||||
/* Represents the currently selected field. This should not be edited directly. */
|
||||
const [selectedField, setSelectedField] = useState<EditableFieldDefinition | undefined>();
|
||||
|
||||
/** Dirty state */
|
||||
/* Dirty state of the field type */
|
||||
const [nextFieldType, setNextFieldType] = useState<EditableFieldDefinition['type'] | undefined>();
|
||||
/* Dirty state of the field format */
|
||||
const [nextFieldFormat, setNextFieldFormat] = useState<
|
||||
EditableFieldDefinition['format'] | undefined
|
||||
>();
|
||||
/* Full dirty definition entry that can be persisted against a stream */
|
||||
const nextFieldDefinition = useMemo(() => {
|
||||
return selectedField
|
||||
? {
|
||||
name: selectedField.name,
|
||||
type: nextFieldType,
|
||||
...(nextFieldFormat && nextFieldType === 'date' ? { format: nextFieldFormat } : {}),
|
||||
}
|
||||
: undefined;
|
||||
}, [nextFieldFormat, nextFieldType, selectedField]);
|
||||
|
||||
const selectField = useCallback(
|
||||
(field: EditableFieldDefinition, selectAndEdit?: boolean) => {
|
||||
setSelectedField(field);
|
||||
setNextFieldType(field.type);
|
||||
setNextFieldFormat(field.format);
|
||||
toggleIsEditing(selectAndEdit !== undefined ? selectAndEdit : false);
|
||||
},
|
||||
[toggleIsEditing]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSelectedField(undefined);
|
||||
setNextFieldType(undefined);
|
||||
setNextFieldFormat(undefined);
|
||||
toggleIsEditing(false);
|
||||
toggleIsSaving(false);
|
||||
setError(undefined);
|
||||
}, [toggleIsEditing, toggleIsSaving]);
|
||||
|
||||
const saveChanges = useMemo(() => {
|
||||
return selectedField &&
|
||||
isFullFieldDefinition(nextFieldDefinition) &&
|
||||
hasChanges(selectedField, nextFieldDefinition)
|
||||
? async () => {
|
||||
toggleIsSaving(true);
|
||||
try {
|
||||
await streamsRepositoryClient.fetch(`PUT /api/streams/{id}/_ingest`, {
|
||||
signal: abortController.signal,
|
||||
params: {
|
||||
path: {
|
||||
id: definition.stream.name,
|
||||
},
|
||||
body: {
|
||||
ingest: {
|
||||
...definition.stream.ingest,
|
||||
wired: {
|
||||
fields: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(definition.stream.ingest.wired.fields).filter(
|
||||
([name, _field]) => name !== nextFieldDefinition.name
|
||||
)
|
||||
),
|
||||
[nextFieldDefinition.name]: omit(nextFieldDefinition, 'name'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
toastsService.addSuccess(
|
||||
i18n.translate('xpack.streams.streamDetailSchemaEditorEditSuccessToast', {
|
||||
defaultMessage: '{field} was successfully edited',
|
||||
values: { field: nextFieldDefinition.name },
|
||||
})
|
||||
);
|
||||
reset();
|
||||
refreshDefinition();
|
||||
refreshUnmappedFields();
|
||||
} catch (e) {
|
||||
toggleIsSaving(false);
|
||||
setError(e);
|
||||
toastsService.addError(e, {
|
||||
title: i18n.translate('xpack.streams.streamDetailSchemaEditorEditErrorToast', {
|
||||
defaultMessage: 'Something went wrong editing the {field} field',
|
||||
values: { field: nextFieldDefinition.name },
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
}, [
|
||||
abortController.signal,
|
||||
definition,
|
||||
nextFieldDefinition,
|
||||
refreshDefinition,
|
||||
refreshUnmappedFields,
|
||||
reset,
|
||||
selectedField,
|
||||
streamsRepositoryClient,
|
||||
toastsService,
|
||||
toggleIsSaving,
|
||||
]);
|
||||
|
||||
return {
|
||||
selectedField,
|
||||
selectField,
|
||||
isEditing,
|
||||
toggleIsEditing,
|
||||
nextFieldType,
|
||||
setNextFieldType,
|
||||
nextFieldFormat,
|
||||
setNextFieldFormat,
|
||||
isSaving,
|
||||
saveChanges,
|
||||
reset,
|
||||
error,
|
||||
nextFieldDefinition,
|
||||
};
|
||||
};
|
||||
|
||||
export const isFullFieldDefinition = (
|
||||
value?: Partial<NamedFieldDefinitionConfig>
|
||||
): value is NamedFieldDefinitionConfig => {
|
||||
return !!value && !!value.name && !!value.type;
|
||||
};
|
||||
|
||||
const hasChanges = (
|
||||
selectedField: Partial<NamedFieldDefinitionConfig>,
|
||||
nextFieldEntry: Partial<NamedFieldDefinitionConfig>
|
||||
) => {
|
||||
return (
|
||||
selectedField.type !== nextFieldEntry.type || selectedField.format !== nextFieldEntry.format
|
||||
);
|
||||
};
|
|
@ -1,36 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiSearchBar, Query } from '@elastic/eui';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export type FilterGroups = Record<string, string[]>;
|
||||
|
||||
export const useQueryAndFilters = () => {
|
||||
const [query, setQuery] = useState<Query | undefined>(EuiSearchBar.Query.MATCH_ALL);
|
||||
const [filterGroups, setFilterGroups] = useState<FilterGroups>({});
|
||||
|
||||
const changeFilterGroups = useCallback(
|
||||
(nextFilterGroups: FilterGroups) => {
|
||||
setFilterGroups({
|
||||
...filterGroups,
|
||||
...nextFilterGroups,
|
||||
});
|
||||
},
|
||||
[filterGroups]
|
||||
);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
filterGroups,
|
||||
changeFilterGroups,
|
||||
};
|
||||
};
|
||||
|
||||
export type SchemaEditorQueryAndFiltersState = ReturnType<typeof useQueryAndFilters>;
|
||||
export type ChangeFilterGroups = ReturnType<typeof useQueryAndFilters>['changeFilterGroups'];
|
|
@ -1,95 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { StreamsRepositoryClient } from '@kbn/streams-plugin/public/api';
|
||||
import { useCallback, useState } from 'react';
|
||||
import useToggle from 'react-use/lib/useToggle';
|
||||
import { useAbortController } from '@kbn/observability-utils-browser/hooks/use_abort_controller';
|
||||
import { ToastsStart } from '@kbn/core-notifications-browser';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { omit } from 'lodash';
|
||||
import { WiredStreamGetResponse } from '@kbn/streams-schema';
|
||||
|
||||
export type SchemaEditorUnpromotingState = ReturnType<typeof useUnpromotingState>;
|
||||
|
||||
export const useUnpromotingState = ({
|
||||
streamsRepositoryClient,
|
||||
definition,
|
||||
refreshDefinition,
|
||||
refreshUnmappedFields,
|
||||
toastsService,
|
||||
}: {
|
||||
streamsRepositoryClient: StreamsRepositoryClient;
|
||||
definition: WiredStreamGetResponse;
|
||||
refreshDefinition: () => void;
|
||||
refreshUnmappedFields: () => void;
|
||||
toastsService: ToastsStart;
|
||||
}) => {
|
||||
const abortController = useAbortController();
|
||||
/* Represents the currently persisted state of the selected field. This should not be edited directly. */
|
||||
const [selectedField, setSelectedField] = useState<string | undefined>();
|
||||
/* Whether changes are being persisted */
|
||||
const [isUnpromotingField, toggleIsUnpromotingField] = useToggle(false);
|
||||
/* Holds errors from saving changes */
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
|
||||
const unpromoteField = useCallback(async () => {
|
||||
if (!selectedField) {
|
||||
return;
|
||||
}
|
||||
toggleIsUnpromotingField(true);
|
||||
try {
|
||||
await streamsRepositoryClient.fetch(`PUT /api/streams/{id}/_ingest`, {
|
||||
signal: abortController.signal,
|
||||
params: {
|
||||
path: {
|
||||
id: definition.stream.name,
|
||||
},
|
||||
body: {
|
||||
ingest: {
|
||||
...definition.stream.ingest,
|
||||
wired: {
|
||||
fields: omit(definition.stream.ingest.wired.fields, selectedField),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
toggleIsUnpromotingField(false);
|
||||
setSelectedField(undefined);
|
||||
refreshDefinition();
|
||||
refreshUnmappedFields();
|
||||
toastsService.addSuccess(
|
||||
i18n.translate('xpack.streams.streamDetailSchemaEditorUnmapSuccessToast', {
|
||||
defaultMessage: '{field} was successfully unmapped',
|
||||
values: { field: selectedField },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
toggleIsUnpromotingField(false);
|
||||
setError(e);
|
||||
toastsService.addError(e, {
|
||||
title: i18n.translate('xpack.streams.streamDetailSchemaEditorUnmapErrorToast', {
|
||||
defaultMessage: 'Something went wrong unmapping the {field} field',
|
||||
values: { field: selectedField },
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
abortController.signal,
|
||||
definition.stream.ingest,
|
||||
definition.stream.name,
|
||||
refreshDefinition,
|
||||
refreshUnmappedFields,
|
||||
selectedField,
|
||||
streamsRepositoryClient,
|
||||
toastsService,
|
||||
toggleIsUnpromotingField,
|
||||
]);
|
||||
|
||||
return { selectedField, setSelectedField, isUnpromotingField, unpromoteField, error };
|
||||
};
|
|
@ -4,22 +4,10 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiPortal, EuiButton } from '@elastic/eui';
|
||||
import { css } from '@emotion/css';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { WiredStreamGetResponse } from '@kbn/streams-schema';
|
||||
import { useEditingState } from './hooks/use_editing_state';
|
||||
import { SchemaEditorFlyout } from './flyout';
|
||||
import { useKibana } from '../../hooks/use_kibana';
|
||||
import { useUnpromotingState } from './hooks/use_unpromoting_state';
|
||||
import { SimpleSearchBar } from './simple_search_bar';
|
||||
import { UnpromoteFieldModal } from './unpromote_field_modal';
|
||||
import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch';
|
||||
import { FieldsTableContainer } from './fields_table';
|
||||
import { FieldTypeFilterGroup } from './filters/type_filter_group';
|
||||
import { useQueryAndFilters } from './hooks/use_query_and_filters';
|
||||
import { FieldStatusFilterGroup } from './filters/status_filter_group';
|
||||
import React from 'react';
|
||||
import { WiredStreamGetResponse, isRootStreamDefinition } from '@kbn/streams-schema';
|
||||
import { SchemaEditor } from '../schema_editor';
|
||||
import { useSchemaFields } from '../schema_editor/hooks/use_schema_fields';
|
||||
|
||||
interface SchemaEditorProps {
|
||||
definition?: WiredStreamGetResponse;
|
||||
|
@ -37,133 +25,23 @@ const Content = ({
|
|||
refreshDefinition,
|
||||
isLoadingDefinition,
|
||||
}: Required<SchemaEditorProps>) => {
|
||||
const {
|
||||
dependencies: {
|
||||
start: {
|
||||
streams: { streamsRepositoryClient },
|
||||
},
|
||||
},
|
||||
core: {
|
||||
notifications: { toasts },
|
||||
},
|
||||
} = useKibana();
|
||||
|
||||
const queryAndFiltersState = useQueryAndFilters();
|
||||
|
||||
const {
|
||||
value: unmappedFieldsValue,
|
||||
loading: isLoadingUnmappedFields,
|
||||
refresh: refreshUnmappedFields,
|
||||
} = useStreamsAppFetch(
|
||||
({ signal }) => {
|
||||
return streamsRepositoryClient.fetch('GET /api/streams/{id}/schema/unmapped_fields', {
|
||||
signal,
|
||||
params: {
|
||||
path: {
|
||||
id: definition.stream.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[definition.stream.name, streamsRepositoryClient]
|
||||
);
|
||||
|
||||
const editingState = useEditingState({
|
||||
definition,
|
||||
streamsRepositoryClient,
|
||||
refreshDefinition,
|
||||
refreshUnmappedFields,
|
||||
toastsService: toasts,
|
||||
});
|
||||
|
||||
const unpromotingState = useUnpromotingState({
|
||||
definition,
|
||||
streamsRepositoryClient,
|
||||
refreshDefinition,
|
||||
refreshUnmappedFields,
|
||||
toastsService: toasts,
|
||||
});
|
||||
|
||||
const { reset } = editingState;
|
||||
|
||||
// If the definition changes (e.g. navigating to parent stream), reset the entire editing state.
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [definition.stream.name, reset]);
|
||||
|
||||
const refreshData = useCallback(() => {
|
||||
refreshDefinition();
|
||||
refreshUnmappedFields();
|
||||
}, [refreshDefinition, refreshUnmappedFields]);
|
||||
const { fields, isLoadingUnmappedFields, refreshFields, unmapField, updateField } =
|
||||
useSchemaFields({
|
||||
definition,
|
||||
refreshDefinition,
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup direction="column">
|
||||
{isLoadingDefinition || isLoadingUnmappedFields ? (
|
||||
<EuiPortal>
|
||||
<EuiProgress size="xs" color="accent" position="fixed" />
|
||||
</EuiPortal>
|
||||
) : null}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<SimpleSearchBar
|
||||
query={queryAndFiltersState.query}
|
||||
onChange={(nextQuery) =>
|
||||
queryAndFiltersState.setQuery(nextQuery.query ?? undefined)
|
||||
}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<FieldTypeFilterGroup onChangeFilterGroup={queryAndFiltersState.changeFilterGroups} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<FieldStatusFilterGroup
|
||||
onChangeFilterGroup={queryAndFiltersState.changeFilterGroups}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
data-test-subj="streamsAppContentRefreshButton"
|
||||
iconType="refresh"
|
||||
onClick={refreshData}
|
||||
>
|
||||
{i18n.translate('xpack.streams.schemaEditor.refreshDataButtonLabel', {
|
||||
defaultMessage: 'Refresh',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
className={css`
|
||||
overflow: auto;
|
||||
`}
|
||||
grow
|
||||
>
|
||||
<FieldsTableContainer
|
||||
definition={definition}
|
||||
query={queryAndFiltersState.query}
|
||||
unmappedFieldsResult={unmappedFieldsValue?.unmappedFields}
|
||||
isLoadingUnmappedFields={isLoadingUnmappedFields}
|
||||
editingState={editingState}
|
||||
unpromotingState={unpromotingState}
|
||||
queryAndFiltersState={queryAndFiltersState}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
{editingState.selectedField && (
|
||||
<SchemaEditorFlyout
|
||||
definition={definition}
|
||||
streamsRepositoryClient={streamsRepositoryClient}
|
||||
{...editingState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{unpromotingState.selectedField && (
|
||||
<UnpromoteFieldModal unpromotingState={unpromotingState} />
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<SchemaEditor
|
||||
fields={fields}
|
||||
isLoading={isLoadingDefinition || isLoadingUnmappedFields}
|
||||
stream={definition.stream}
|
||||
onFieldUnmap={unmapField}
|
||||
onFieldUpdate={updateField}
|
||||
onRefreshData={refreshFields}
|
||||
withControls
|
||||
withFieldSimulation
|
||||
withTableActions={!isRootStreamDefinition(definition.stream)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiSearchBar, EuiSearchBarProps } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
/* Simple search bar that doesn't attempt to integrate with unified search */
|
||||
export const SimpleSearchBar = ({
|
||||
query,
|
||||
onChange,
|
||||
}: {
|
||||
query: EuiSearchBarProps['query'];
|
||||
onChange: Required<EuiSearchBarProps>['onChange'];
|
||||
}) => {
|
||||
return (
|
||||
<EuiSearchBar
|
||||
query={query}
|
||||
box={{
|
||||
incremental: true,
|
||||
}}
|
||||
onChange={(nextQuery) => {
|
||||
onChange(nextQuery);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,58 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import {
|
||||
EuiButton,
|
||||
EuiModal,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
useGeneratedHtmlId,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { SchemaEditorUnpromotingState } from './hooks/use_unpromoting_state';
|
||||
|
||||
export const UnpromoteFieldModal = ({
|
||||
unpromotingState,
|
||||
}: {
|
||||
unpromotingState: SchemaEditorUnpromotingState;
|
||||
}) => {
|
||||
const { setSelectedField, selectedField, unpromoteField, isUnpromotingField } = unpromotingState;
|
||||
|
||||
const modalTitleId = useGeneratedHtmlId();
|
||||
|
||||
if (!selectedField) return null;
|
||||
|
||||
return (
|
||||
<EuiModal aria-labelledby={modalTitleId} onClose={() => setSelectedField(undefined)}>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle id={modalTitleId}>{selectedField}</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
|
||||
<EuiModalBody>
|
||||
{i18n.translate('xpack.streams.unpromoteFieldModal.unpromoteFieldWarning', {
|
||||
defaultMessage: 'Are you sure you want to unmap this field from template mappings?',
|
||||
})}
|
||||
</EuiModalBody>
|
||||
|
||||
<EuiModalFooter>
|
||||
<EuiButton
|
||||
data-test-subj="streamsAppUnpromoteFieldModalCloseButton"
|
||||
onClick={() => unpromoteField()}
|
||||
disabled={isUnpromotingField}
|
||||
color="danger"
|
||||
fill
|
||||
>
|
||||
{i18n.translate('xpack.streams.unpromoteFieldModal.unpromoteFieldButtonLabel', {
|
||||
defaultMessage: 'Unmap field',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiModalFooter>
|
||||
</EuiModal>
|
||||
);
|
||||
};
|
|
@ -31,7 +31,6 @@
|
|||
"@kbn/observability-utils-server",
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/calculate-auto",
|
||||
"@kbn/core-notifications-browser",
|
||||
"@kbn/kibana-react-plugin",
|
||||
"@kbn/es-query",
|
||||
"@kbn/server-route-repository-client",
|
||||
|
@ -45,7 +44,6 @@
|
|||
"@kbn/code-editor",
|
||||
"@kbn/ui-theme",
|
||||
"@kbn/navigation-plugin",
|
||||
"@kbn/core-notifications-browser",
|
||||
"@kbn/index-lifecycle-management-common-shared",
|
||||
"@kbn/streams-schema",
|
||||
"@kbn/react-hooks",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue