Skip to content

Commit

Permalink
[Streams 🌊] Extract schema editor component (elastic#209514)
Browse files Browse the repository at this point in the history
## 📓 Summary

Closes elastic/streams-program#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)}
/>
```
  • Loading branch information
tonyghiani authored Feb 5, 2025
1 parent 6635fe5 commit ddf3bdc
Show file tree
Hide file tree
Showing 40 changed files with 1,370 additions and 1,498 deletions.
2 changes: 2 additions & 0 deletions src/platform/packages/shared/kbn-react-field/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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

0 comments on commit ddf3bdc

Please sign in to comment.