[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:
Marco Antonio Ghiani 2025-02-05 16:48:03 +01:00 committed by GitHub
parent 6635fe501c
commit ddf3bdcce3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1370 additions and 1498 deletions

View file

@ -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';

View file

@ -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"`;

View file

@ -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();
});

View file

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

View file

@ -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';

View file

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

View file

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

View file

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

View file

@ -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} />;
};

View file

@ -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={{

View file

@ -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 (

View file

@ -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 (

View file

@ -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 = ({

View file

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

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; 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,
}))}
/>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; 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);

View file

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

View file

@ -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;
};

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; 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;
};

View file

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

View file

@ -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;
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 };
};

View file

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

View file

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

View file

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

View file

@ -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",