[8.x] [Streams 🌊] Update condition editor enabling and fixes (#218055) (#218106)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Streams 🌊] Update condition editor enabling and fixes
(#218055)](https://github.com/elastic/kibana/pull/218055)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Marco Antonio
Ghiani","email":"marcoantonio.ghiani01@gmail.com"},"sourceCommit":{"committedDate":"2025-04-14T12:25:46Z","message":"[Streams
🌊] Update condition editor enabling and fixes (#218055)\n\n## 📓
Summary\n\nCloses #217884 \n\n- Updates the condition editor to have a
more consistent behaviour for\nenabled/disabled routing.\n- A more
explicit tooltip is added to describe how the status flag\naffects the
routing behaviour.\n- The status switch is visible by default, while
before it was shown\nonly in edit mode for a routing condition.\n -
Fixed crashes when manually working on the syntax editor.\n- Removes the
routing status flag from the condition editor in the\nprocessors'
config.\n\n<img width=\"763\" alt=\"Screenshot 2025-04-14 at 10 23
42\"\nsrc=\"https://github.com/user-attachments/assets/8521739a-ac53-4751-9ad3-4400a84c5a8d\"\n/>","sha":"d46a89e4a547054077d31de1a4b281615734c6a4","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:obs-ux-logs","backport:version","Feature:Streams","v9.1.0","v8.19.0"],"title":"[Streams
🌊] Update condition editor enabling and
fixes","number":218055,"url":"https://github.com/elastic/kibana/pull/218055","mergeCommit":{"message":"[Streams
🌊] Update condition editor enabling and fixes (#218055)\n\n## 📓
Summary\n\nCloses #217884 \n\n- Updates the condition editor to have a
more consistent behaviour for\nenabled/disabled routing.\n- A more
explicit tooltip is added to describe how the status flag\naffects the
routing behaviour.\n- The status switch is visible by default, while
before it was shown\nonly in edit mode for a routing condition.\n -
Fixed crashes when manually working on the syntax editor.\n- Removes the
routing status flag from the condition editor in the\nprocessors'
config.\n\n<img width=\"763\" alt=\"Screenshot 2025-04-14 at 10 23
42\"\nsrc=\"https://github.com/user-attachments/assets/8521739a-ac53-4751-9ad3-4400a84c5a8d\"\n/>","sha":"d46a89e4a547054077d31de1a4b281615734c6a4"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/218055","number":218055,"mergeCommit":{"message":"[Streams
🌊] Update condition editor enabling and fixes (#218055)\n\n## 📓
Summary\n\nCloses #217884 \n\n- Updates the condition editor to have a
more consistent behaviour for\nenabled/disabled routing.\n- A more
explicit tooltip is added to describe how the status flag\naffects the
routing behaviour.\n- The status switch is visible by default, while
before it was shown\nonly in edit mode for a routing condition.\n -
Fixed crashes when manually working on the syntax editor.\n- Removes the
routing status flag from the condition editor in the\nprocessors'
config.\n\n<img width=\"763\" alt=\"Screenshot 2025-04-14 at 10 23
42\"\nsrc=\"https://github.com/user-attachments/assets/8521739a-ac53-4751-9ad3-4400a84c5a8d\"\n/>","sha":"d46a89e4a547054077d31de1a4b281615734c6a4"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Marco Antonio Ghiani <marcoantonio.ghiani01@gmail.com>
This commit is contained in:
Kibana Machine 2025-04-14 16:37:29 +02:00 committed by GitHub
parent 161e3d508f
commit 0c8a0b29db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 215 additions and 210 deletions

View file

@ -5,153 +5,71 @@
* 2.0.
*/
import React from 'react';
import useToggle from 'react-use/lib/useToggle';
import {
EuiCodeBlock,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiIconTip,
EuiSelect,
EuiSelectOption,
EuiSwitch,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import {
BinaryFilterCondition,
Condition,
FilterCondition,
isCondition,
isNeverCondition,
} from '@kbn/streams-schema';
import React, { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/css';
import { CodeEditor } from '@kbn/code-editor';
import { isPlainObject } from 'lodash';
import {
EMPTY_EQUALS_CONDITION,
ALWAYS_CONDITION,
NEVER_CONDITION,
alwaysToEmptyEquals,
emptyEqualsToAlways,
} from '../../../util/condition';
export function ConditionEditor(props: {
condition: Condition;
onConditionChange?: (condition: Condition) => void;
isNew?: boolean;
}) {
const normalizedCondition = alwaysToEmptyEquals(props.condition);
export type RoutingConditionEditorProps = ConditionEditorProps;
const handleConditionChange = (condition: Condition) => {
props.onConditionChange?.(emptyEqualsToAlways(condition));
};
export function RoutingConditionEditor(props: RoutingConditionEditorProps) {
const isEnabled = !isNeverCondition(props.condition);
return (
<ConditionForm
condition={normalizedCondition}
onConditionChange={handleConditionChange}
isNew={props.isNew}
/>
);
}
export function ConditionForm(props: {
condition: Condition;
onConditionChange: (condition: Condition) => void;
isNew?: boolean;
}) {
const [syntaxEditor, setSyntaxEditor] = React.useState(() =>
Boolean(props.condition && !('operator' in props.condition))
);
const [jsonCondition, setJsonCondition] = React.useState<string | null>(() =>
JSON.stringify(props.condition, null, 2)
);
useEffect(() => {
if (!syntaxEditor && props.condition) {
setJsonCondition(JSON.stringify(props.condition, null, 2));
}
}, [syntaxEditor, props.condition]);
return (
<EuiFlexGroup direction="column" gutterSize="s">
{!props.isNew && (
<>
<EuiFlexItem grow>
<EuiText
className={css`
font-weight: bold;
`}
size="xs"
>
{i18n.translate('xpack.streams.conditionEditor.title', { defaultMessage: 'Status' })}
</EuiText>
</EuiFlexItem>
<EuiToolTip
content={i18n.translate('xpack.streams.conditionEditor.disableTooltip', {
defaultMessage: 'Route no documents to this stream without deleting existing data',
<EuiForm fullWidth>
<EuiFormRow
label={
<EuiFlexGroup gutterSize="xs" alignItems="center">
{i18n.translate('xpack.streams.conditionEditor.title', {
defaultMessage: 'Status',
})}
>
<EuiSwitch
label={i18n.translate('xpack.streams.conditionEditor.switch', {
defaultMessage: 'Enabled',
<EuiIconTip
content={i18n.translate('xpack.streams.conditionEditor.disableTooltip', {
defaultMessage:
'When disabled, the routing rule stops sending documents to this stream. It does not remove existing data.',
})}
compressed
checked={!isNeverCondition(props.condition)}
onChange={() => {
props.onConditionChange(
isNeverCondition(props.condition) ? EMPTY_EQUALS_CONDITION : { never: {} }
);
setSyntaxEditor(false);
}}
/>
</EuiToolTip>
</>
)}
{(props.isNew || !isNeverCondition(props.condition)) && (
<>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow>
<EuiText
className={css`
font-weight: bold;
`}
size="xs"
>
{i18n.translate('xpack.streams.conditionEditor.title', {
defaultMessage: 'Condition',
})}
</EuiText>
</EuiFlexItem>
<EuiSwitch
label={i18n.translate('xpack.streams.conditionEditor.switch', {
defaultMessage: 'Syntax editor',
})}
compressed
checked={syntaxEditor}
onChange={() => setSyntaxEditor(!syntaxEditor)}
/>
</EuiFlexGroup>
{syntaxEditor ? (
<CodeEditor
height={200}
languageId="json"
value={jsonCondition || ''}
onChange={(e) => {
setJsonCondition(e);
try {
const condition = JSON.parse(e);
props.onConditionChange(condition);
} catch (error: unknown) {
// do nothing
}
}}
/>
) : !props.condition || 'operator' in props.condition ? (
<FilterForm
condition={(props.condition as FilterCondition) || EMPTY_EQUALS_CONDITION}
onConditionChange={props.onConditionChange}
/>
) : (
<pre>{JSON.stringify(props.condition, null, 2)}</pre>
)}
</>
)}
</EuiFlexGroup>
}
>
<EuiSwitch
label={i18n.translate('xpack.streams.conditionEditor.switch', {
defaultMessage: 'Enabled',
})}
compressed
checked={isEnabled}
onChange={(event) => {
props.onConditionChange(event.target.checked ? ALWAYS_CONDITION : NEVER_CONDITION);
}}
/>
</EuiFormRow>
{isEnabled && <ConditionEditor {...props} />}
</EuiForm>
);
}
@ -173,77 +91,141 @@ const operatorMap = {
notExists: i18n.translate('xpack.streams.filter.notExists', { defaultMessage: 'not exists' }),
};
const operatorOptions: EuiSelectOption[] = Object.entries(operatorMap).map(([value, text]) => ({
value,
text,
}));
export interface ConditionEditorProps {
condition: Condition;
onConditionChange: (condition: Condition) => void;
}
export function ConditionEditor(props: ConditionEditorProps) {
const isInvalidCondition = !isCondition(props.condition);
const condition = alwaysToEmptyEquals(props.condition);
const isFilterCondition = isPlainObject(condition) && 'operator' in condition;
const [usingSyntaxEditor, toggleSyntaxEditor] = useToggle(!isFilterCondition);
const handleConditionChange = (updatedCondition: Condition) => {
props.onConditionChange(emptyEqualsToAlways(updatedCondition));
};
return (
<EuiFormRow
label={i18n.translate('xpack.streams.conditionEditor.title', {
defaultMessage: 'Condition',
})}
labelAppend={
<EuiSwitch
label={i18n.translate('xpack.streams.conditionEditor.switch', {
defaultMessage: 'Syntax editor',
})}
compressed
checked={usingSyntaxEditor}
onChange={toggleSyntaxEditor}
/>
}
isInvalid={isInvalidCondition}
error={
isInvalidCondition
? i18n.translate('xpack.streams.conditionEditor.error', {
defaultMessage: 'The condition is invalid or in unrecognized format.',
})
: undefined
}
>
{usingSyntaxEditor ? (
<CodeEditor
height={200}
languageId="json"
value={JSON.stringify(condition, null, 2)}
onChange={(value) => {
try {
handleConditionChange(JSON.parse(value));
} catch (error: unknown) {
// do nothing
}
}}
/>
) : isFilterCondition ? (
<FilterForm condition={condition} onConditionChange={handleConditionChange} />
) : (
<EuiCodeBlock language="json" paddingSize="m" isCopyable>
{JSON.stringify(condition, null, 2)}
</EuiCodeBlock>
)}
</EuiFormRow>
);
}
function FilterForm(props: {
condition: FilterCondition;
onConditionChange: (condition: FilterCondition) => void;
}) {
const handleConditionChange = (updatedCondition: Partial<FilterCondition>) => {
props.onConditionChange({
...props.condition,
...updatedCondition,
} as FilterCondition);
};
const handleOperatorChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const newCondition: Partial<FilterCondition> = { ...props.condition };
const newOperator = event.target.value;
if (newOperator === 'exists' || newOperator === 'notExists') {
if ('value' in newCondition) delete newCondition.value;
} else if (!('value' in newCondition)) {
(newCondition as BinaryFilterCondition).value = '';
}
props.onConditionChange({
...newCondition,
operator: newOperator,
} as FilterCondition);
};
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow>
<EuiFieldText
data-test-subj="streamsAppFilterFormFieldText"
aria-label={i18n.translate('xpack.streams.filter.field', { defaultMessage: 'Field' })}
compressed
placeholder={i18n.translate('xpack.streams.filter.fieldPlaceholder', {
defaultMessage: 'Field',
})}
value={props.condition.field}
onChange={(e) => {
props.onConditionChange({ ...props.condition, field: e.target.value });
}}
/>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiSelect
aria-label={i18n.translate('xpack.streams.filter.operator', {
defaultMessage: 'Operator',
})}
data-test-subj="streamsAppFilterFormSelect"
options={
Object.entries(operatorMap).map(([value, text]) => ({
value,
text,
})) as Array<{ value: FilterCondition['operator']; text: string }>
}
value={props.condition.operator}
compressed
onChange={(e) => {
const newCondition: Partial<FilterCondition> = {
...props.condition,
};
const newOperator = e.target.value as FilterCondition['operator'];
if (newOperator === 'exists' || newOperator === 'notExists') {
if ('value' in newCondition) delete newCondition.value;
} else if (!('value' in newCondition)) {
(newCondition as BinaryFilterCondition).value = '';
}
props.onConditionChange({
...newCondition,
operator: newOperator,
} as FilterCondition);
}}
/>
</EuiFlexItem>
<EuiFieldText
data-test-subj="streamsAppFilterFormFieldText"
aria-label={i18n.translate('xpack.streams.filter.field', { defaultMessage: 'Field' })}
compressed
placeholder={i18n.translate('xpack.streams.filter.fieldPlaceholder', {
defaultMessage: 'Field',
})}
value={props.condition.field}
onChange={(e) => {
handleConditionChange({ field: e.target.value });
}}
/>
<EuiSelect
aria-label={i18n.translate('xpack.streams.filter.operator', {
defaultMessage: 'Operator',
})}
data-test-subj="streamsAppFilterFormSelect"
options={operatorOptions}
value={props.condition.operator}
compressed
onChange={handleOperatorChange}
/>
{'value' in props.condition && (
<EuiFlexItem grow>
<EuiFieldText
aria-label={i18n.translate('xpack.streams.filter.value', { defaultMessage: 'Value' })}
placeholder={i18n.translate('xpack.streams.filter.valuePlaceholder', {
defaultMessage: 'Value',
})}
compressed
value={String(props.condition.value)}
data-test-subj="streamsAppFilterFormValueText"
onChange={(e) => {
props.onConditionChange({
...props.condition,
value: e.target.value,
} as BinaryFilterCondition);
}}
/>
</EuiFlexItem>
<EuiFieldText
aria-label={i18n.translate('xpack.streams.filter.value', { defaultMessage: 'Value' })}
placeholder={i18n.translate('xpack.streams.filter.valuePlaceholder', {
defaultMessage: 'Value',
})}
compressed
value={String(props.condition.value)}
data-test-subj="streamsAppFilterFormValueText"
onChange={(e) => {
handleConditionChange({ value: e.target.value });
}}
/>
)}
</EuiFlexGroup>
);

View file

@ -12,6 +12,7 @@ import {
isBinaryFilterCondition,
isFilterCondition,
isNeverCondition,
isOrCondition,
} from '@kbn/streams-schema';
import React from 'react';
import { i18n } from '@kbn/i18n';
@ -52,7 +53,7 @@ export function ConditionMessage({ condition }: { condition: Condition }) {
return (
<FormattedMessage
id="xpack.streams.andDisplay.andLabel"
defaultMessage="{left} and {right}"
defaultMessage="{left} AND {right}"
values={{
left: <ConditionMessage condition={condition.and[0]} />,
right: (
@ -66,22 +67,37 @@ export function ConditionMessage({ condition }: { condition: Condition }) {
}}
/>
);
}
if (condition.or.length === 0) {
return '';
} else if (isOrCondition(condition)) {
if (condition.or.length === 0) {
return '';
}
if (condition.or.length === 1) {
return <ConditionMessage condition={condition.or[0]} />;
}
return (
<FormattedMessage
id="xpack.streams.orDisplay.orLabel"
defaultMessage="{left} OR {right}"
values={{
left: <ConditionMessage condition={condition.or[0]} />,
right: (
<ConditionMessage
condition={{
...condition,
or: condition.or.slice(1),
}}
/>
),
}}
/>
);
}
if (condition.or.length === 1) {
return <ConditionMessage condition={condition.or[0]} />;
}
return (
<FormattedMessage
id="xpack.streams.orDisplay.orLabel"
defaultMessage="{left} or {right}"
values={{
left: <ConditionMessage condition={condition.or[0]} />,
right: <ConditionMessage condition={condition.or[1]} />,
}}
id="xpack.streams.orDisplay.invalidConditionLabel"
defaultMessage="Invalid condition format"
/>
);
}

View file

@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n';
import { css } from '@emotion/css';
import { cloneDeep } from 'lodash';
import React from 'react';
import { EMPTY_EQUALS_CONDITION } from '../../../util/condition';
import { ALWAYS_CONDITION } from '../../../util/condition';
import { NestedView } from '../../nested_view';
import { useRoutingStateContext } from './hooks/routing_state';
import { CurrentStreamEntry } from './current_stream_entry';
@ -82,7 +82,7 @@ export function ChildStreamList({ availableStreams }: { availableStreams: string
isNew: true,
child: {
destination: `${definition.stream.name}.child`,
if: cloneDeep(EMPTY_EQUALS_CONDITION),
if: cloneDeep(ALWAYS_CONDITION),
},
});
}}

View file

@ -8,7 +8,7 @@
import { EuiFlexGroup, EuiButton, EuiFlexItem, EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { IngestUpsertRequest } from '@kbn/streams-schema';
import { IngestUpsertRequest, isCondition } from '@kbn/streams-schema';
import React from 'react';
import { useAbortController } from '@kbn/react-hooks';
import { useKibana } from '../../../hooks/use_kibana';
@ -206,7 +206,12 @@ export function ControlBar() {
>
<EuiButton
isLoading={routingAppState.saveInProgress}
disabled={routingAppState.saveInProgress || !definition.privileges.manage}
disabled={
routingAppState.saveInProgress ||
!definition.privileges.manage ||
(routingAppState.childUnderEdit &&
!isCondition(routingAppState.childUnderEdit.child.if))
}
onClick={saveOrUpdateChildren}
data-test-subj="streamsAppStreamDetailRoutingSaveButton"
>

View file

@ -9,7 +9,7 @@ import { EuiPanel, EuiFlexGroup, EuiFormRow, EuiFieldText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { RoutingDefinition } from '@kbn/streams-schema';
import React, { useEffect, useRef } from 'react';
import { ConditionEditor } from '../condition_editor';
import { RoutingConditionEditor } from '../condition_editor';
import { ControlBar } from './control_bar';
export function NewRoutingStreamEntry({
@ -51,8 +51,7 @@ export function NewRoutingStreamEntry({
}}
/>
</EuiFormRow>
<ConditionEditor
isNew
<RoutingConditionEditor
condition={child.if}
onConditionChange={(condition) => {
onChildChange({

View file

@ -22,8 +22,7 @@ import { RoutingDefinition, isDescendantOf, isNeverCondition } from '@kbn/stream
import React from 'react';
import { css } from '@emotion/css';
import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router';
import { alwaysToEmptyEquals } from '../../../util/condition';
import { ConditionEditor } from '../condition_editor';
import { RoutingConditionEditor } from '../condition_editor';
import { ConditionMessage } from '../condition_message';
import { ControlBar } from './control_bar';
@ -140,8 +139,8 @@ export function RoutingStreamEntry({
</EuiFlexGroup>
{edit && (
<EuiFlexGroup direction="column" gutterSize="s">
<ConditionEditor
condition={alwaysToEmptyEquals(child.if)}
<RoutingConditionEditor
condition={child.if}
onConditionChange={(condition) => {
onChildChange({
...child,

View file

@ -12,6 +12,7 @@ import {
WiredStreamGetResponse,
conditionToQueryDsl,
getFields,
isAlwaysCondition,
} from '@kbn/streams-schema';
import useToggle from 'react-use/lib/useToggle';
import { MappingRuntimeField, MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
@ -51,7 +52,7 @@ export const useAsyncSample = (options: Options) => {
const convertedCondition = useMemo(() => {
const condition = options.condition ? emptyEqualsToAlways(options.condition) : undefined;
return condition && 'always' in condition ? undefined : condition;
return condition && isAlwaysCondition(condition) ? undefined : condition;
}, [options.condition]);
useEffect(() => {

View file

@ -10,6 +10,7 @@ import {
type AlwaysCondition,
type BinaryFilterCondition,
type Condition,
NeverCondition,
} from '@kbn/streams-schema';
import { cloneDeep, isEqual } from 'lodash';
@ -21,6 +22,8 @@ export const EMPTY_EQUALS_CONDITION: BinaryFilterCondition = Object.freeze({
export const ALWAYS_CONDITION: AlwaysCondition = Object.freeze({ always: {} });
export const NEVER_CONDITION: NeverCondition = Object.freeze({ never: {} });
export function alwaysToEmptyEquals<T extends Condition>(condition: T): Exclude<T, AlwaysCondition>;
export function alwaysToEmptyEquals(condition: Condition) {