Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6689a90
add input validation for short and long stream names to ui and backend
couvq Nov 11, 2025
33bd86c
commit changes for demo
couvq Nov 12, 2025
bd647be
update backend validation to use prefix and add integration tests
couvq Nov 12, 2025
c0fc499
add unit tests for getHelpText utility function
couvq Nov 12, 2025
b59107a
merge conflict updates and refactors
couvq Nov 13, 2025
cb18a39
consolidate max stream name constant into one location
couvq Nov 14, 2025
dce1011
fix max server side validation logic
couvq Nov 14, 2025
f3ce442
move stream length validity check to wired stream class
couvq Nov 20, 2025
dda253a
rebase with main
couvq Nov 21, 2025
3dba904
update fork stream tests to validate new error messages
couvq Nov 21, 2025
2952681
remove test config changes that were not meant to be committed
couvq Nov 21, 2025
abe410e
disable save button when stream name is invalid
couvq Nov 25, 2025
1543f3f
more useful . error message for creating child stream
couvq Nov 25, 2025
7707cf2
refactor stream validity check custom hook to prevent looping and enc…
couvq Nov 26, 2025
8f1109c
refactor child stream input logic into custom hook
couvq Nov 26, 2025
182b5db
add child stream input validation to ai suggestions using custom hook
couvq Nov 26, 2025
8b5c1ce
refactor child stream input logic to debounce input
couvq Nov 29, 2025
518e1c0
handle edge case involving stream names exisiting
couvq Nov 29, 2025
157778b
add unit tests for help and error text helper functions
couvq Nov 30, 2025
308765d
add unit tests for useChildStreamInput hook
couvq Nov 30, 2025
6348d46
be more explicit about dot present rootchildexists error case
couvq Nov 30, 2025
82ad23f
consolidate stream name form row test and utils into one test file
couvq Dec 1, 2025
48b4288
memoize expensive array methods so prevent them recalculating on ever…
couvq Dec 1, 2025
87a4e44
refactored debounce responsibility to parent as it is not the inputs …
couvq Dec 1, 2025
1a4197c
add uppercase input validation
couvq Dec 1, 2025
69ea442
add backend validation for uppercase check
couvq Dec 1, 2025
4d4200c
fix scout tests except for ai suggestions
couvq Dec 3, 2025
1db4d2b
fix ai suggestion tests
couvq Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions x-pack/platform/plugins/shared/streams/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,5 @@ export const STREAMS_TIERED_FEATURES = [
];

export const FAILURE_STORE_SELECTOR = '::failures';

export const MAX_STREAM_NAME_LENGTH = 200;
6 changes: 5 additions & 1 deletion x-pack/platform/plugins/shared/streams/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import type { StreamsPluginSetup, StreamsPluginStart } from './types';

export type { StreamsPluginSetup, StreamsPluginStart };

export { STREAMS_API_PRIVILEGES, STREAMS_UI_PRIVILEGES } from '../common/constants';
export {
STREAMS_API_PRIVILEGES,
STREAMS_UI_PRIVILEGES,
MAX_STREAM_NAME_LENGTH,
} from '../common/constants';

export {
excludeFrozenQuery,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
isDisabledLifecycleFailureStore,
isInheritFailureStore,
} from '@kbn/streams-schema/src/models/ingest/failure_store';
import { MAX_STREAM_NAME_LENGTH } from '../../../../../common/constants';
import { generateLayer } from '../../component_templates/generate_layer';
import { getComponentTemplateName } from '../../component_templates/name';
import { isDefinitionNotFoundError } from '../../errors/definition_not_found_error';
Expand Down Expand Up @@ -371,7 +372,29 @@ export class WiredStream extends StreamActiveRecord<Streams.WiredStream.Definiti

// validate routing
const children: Set<string> = new Set();
const prefix = this.definition.name + '.';
for (const routing of this._definition.ingest.wired.routing) {
const hasUpperCaseChars = routing.destination !== routing.destination.toLowerCase();
if (hasUpperCaseChars) {
return {
isValid: false,
errors: [new Error(`Stream name cannot contain uppercase characters.`)],
};
}
if (routing.destination.length <= prefix.length) {
return {
isValid: false,
errors: [new Error(`Stream name must not be empty.`)],
};
}
if (routing.destination.length > MAX_STREAM_NAME_LENGTH) {
return {
isValid: false,
errors: [
new Error(`Stream name cannot be longer than ${MAX_STREAM_NAME_LENGTH} characters.`),
],
};
}
if (children.has(routing.destination)) {
return {
isValid: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ import {
routingConverter,
} from './utils';

export const AddRoutingRuleControls = () => {
interface AddRoutingRuleControlsProps {
isStreamNameValid: boolean;
}

export const AddRoutingRuleControls = ({ isStreamNameValid }: AddRoutingRuleControlsProps) => {
const routingSnapshot = useStreamsRoutingSelector((snapshot) => snapshot);
const { cancelChanges, forkStream } = useStreamRoutingEvents();
const [isRequestPreviewFlyoutOpen, setIsRequestPreviewFlyoutOpen] = React.useState(false);
Expand Down Expand Up @@ -84,7 +88,7 @@ export const AddRoutingRuleControls = () => {
<PrivilegesTooltip hasPrivileges={hasPrivileges}>
<SaveButton
isLoading={isForking}
isDisabled={!canForkRouting}
isDisabled={!canForkRouting || !isStreamNameValid}
onClick={() => forkStream()}
/>
</PrivilegesTooltip>
Expand Down Expand Up @@ -187,21 +191,21 @@ export const EditRoutingRuleControls = ({
export const EditSuggestedRuleControls = ({
onSave,
onAccept,
nameError,
conditionError,
isStreamNameValid,
}: {
onSave?: () => void;
onAccept: () => void;
nameError?: string;
conditionError?: string;
isStreamNameValid: boolean;
}) => {
const routingSnapshot = useStreamsRoutingSelector((snapshot) => snapshot);
const { cancelChanges } = useStreamRoutingEvents();

const canSave = routingSnapshot.can({ type: 'suggestion.saveSuggestion' });
const hasPrivileges = routingSnapshot.context.definition.privileges.manage;

const hasValidationErrors = !!nameError || !!conditionError;
const hasValidationErrors = !!conditionError;
const isUpdateDisabled = hasValidationErrors || !canSave;

const handleAccept = () => {
Expand All @@ -220,7 +224,7 @@ export const EditSuggestedRuleControls = ({
<PrivilegesTooltip hasPrivileges={hasPrivileges}>
<UpdateAndAcceptButton
isLoading={false}
isDisabled={isUpdateDisabled}
isDisabled={isUpdateDisabled || !isStreamNameValid}
onClick={handleAccept}
/>
</PrivilegesTooltip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { EuiPanel, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/css';
import { RoutingConditionEditor } from './routing_condition_editor';
import { EditRoutingRuleControls } from './control_bars';
import { StreamNameFormRow } from './stream_name_form_row';
import { StreamNameFormRow, useChildStreamInput } from './stream_name_form_row';
import type { RoutingDefinitionWithUIAttributes } from './types';

export function EditRoutingStreamEntry({
Expand All @@ -21,6 +21,7 @@ export function EditRoutingStreamEntry({
routingRule: RoutingDefinitionWithUIAttributes;
}) {
const { euiTheme } = useEuiTheme();
const { partitionName, prefix } = useChildStreamInput(routingRule.destination);

return (
<EuiPanel
Expand All @@ -30,7 +31,7 @@ export function EditRoutingStreamEntry({
data-test-subj={`routingRule-${routingRule.destination}`}
>
<EuiFlexGroup direction="column" gutterSize="m">
<StreamNameFormRow value={routingRule.destination} readOnly />
<StreamNameFormRow partitionName={partitionName} prefix={prefix} readOnly />
<RoutingConditionEditor
condition={routingRule.where}
status={routingRule.status}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@ import { EuiFlexGroup, EuiPanel, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useRef } from 'react';
import { AddRoutingRuleControls } from './control_bars';
import { RoutingConditionEditor } from './routing_condition_editor';
import {
selectCurrentRule,
useStreamRoutingEvents,
useStreamsRoutingSelector,
} from './state_management/stream_routing_state_machine';
import { RoutingConditionEditor } from './routing_condition_editor';
import { StreamNameFormRow } from './stream_name_form_row';
import { StreamNameFormRow, useChildStreamInput } from './stream_name_form_row';

export function NewRoutingStreamEntry() {
const panelRef = useRef<HTMLDivElement>(null);

const { changeRule } = useStreamRoutingEvents();
const { changeRule, changeRuleDebounced } = useStreamRoutingEvents();
const currentRule = useStreamsRoutingSelector((snapshot) => selectCurrentRule(snapshot.context));

useEffect(() => {
Expand All @@ -29,14 +29,22 @@ export function NewRoutingStreamEntry() {
}
}, []);

const { setLocalStreamName, isStreamNameValid, partitionName, prefix, helpText, errorMessage } =
useChildStreamInput(currentRule.destination, false);

return (
<div ref={panelRef}>
<EuiPanel hasShadow={false} hasBorder paddingSize="m">
<EuiFlexGroup gutterSize="m" direction="column">
<StreamNameFormRow
value={currentRule.destination}
onChange={(value) => changeRule({ destination: value })}
onChange={(value) => changeRuleDebounced({ destination: value })}
setLocalStreamName={setLocalStreamName}
autoFocus
partitionName={partitionName}
prefix={prefix}
helpText={helpText}
errorMessage={errorMessage}
isStreamNameValid={isStreamNameValid}
/>
<EuiFlexGroup gutterSize="s" direction="column">
<RoutingConditionEditor
Expand All @@ -51,7 +59,7 @@ export function NewRoutingStreamEntry() {
})}
</EuiText>
</EuiFlexGroup>
<AddRoutingRuleControls />
<AddRoutingRuleControls isStreamNameValid={isStreamNameValid} />
</EuiFlexGroup>
</EuiPanel>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
} from '../state_management/stream_routing_state_machine/use_stream_routing';
import { SelectablePanel } from './selectable_panel';
import { ConditionPanel, VerticalRule } from '../../shared';
import { StreamNameFormRow } from '../stream_name_form_row';
import { StreamNameFormRow, useChildStreamInput } from '../stream_name_form_row';
import { RoutingConditionEditor } from '../routing_condition_editor';
import { processCondition } from '../utils';
import { EditSuggestedRuleControls } from '../control_bars';
Expand All @@ -53,7 +53,7 @@ export function SuggestedStreamPanel({
onSave?: () => void;
}) {
const routingSnapshot = useStreamsRoutingSelector((snapshot) => snapshot);
const { changeSuggestionName, changeSuggestionCondition, reviewSuggestedRule } =
const { changeSuggestionNameDebounced, changeSuggestionCondition, reviewSuggestedRule } =
useStreamRoutingEvents();

const editedSuggestion = routingSnapshot.context.editedSuggestion;
Expand All @@ -71,22 +71,6 @@ export function SuggestedStreamPanel({
selectedPreview.name === currentSuggestion.name
);

const nameError = React.useMemo(() => {
if (!isEditing) return undefined;

const isDuplicateName = routingSnapshot.context.routing.some(
(r) => r.destination === currentSuggestion.name
);

if (isDuplicateName) {
return i18n.translate('xpack.streams.streamDetailRouting.nameConflictError', {
defaultMessage: 'A stream with this name already exists',
});
}

return undefined;
}, [isEditing, currentSuggestion.name, routingSnapshot.context.routing]);

const conditionError = React.useMemo(() => {
if (!isEditing) return undefined;

Expand All @@ -104,24 +88,30 @@ export function SuggestedStreamPanel({

const handleNameChange = (name: string) => {
if (!isEditing) return;
changeSuggestionName(name);
changeSuggestionNameDebounced(name);
};

const handleConditionChange = (condition: any) => {
if (!isEditing) return;
changeSuggestionCondition(condition);
};

const { isStreamNameValid, setLocalStreamName, partitionName, prefix, helpText, errorMessage } =
useChildStreamInput(currentSuggestion.name, false);

if (isEditing) {
return (
<SelectablePanel paddingSize="m" isSelected={isSelected}>
<EuiFlexGroup direction="column" gutterSize="m">
<StreamNameFormRow
value={currentSuggestion.name}
onChange={handleNameChange}
setLocalStreamName={setLocalStreamName}
partitionName={partitionName}
prefix={prefix}
helpText={helpText}
errorMessage={errorMessage}
autoFocus
error={nameError}
isInvalid={!!nameError}
isStreamNameValid={isStreamNameValid}
/>
<RoutingConditionEditor
status="enabled"
Expand All @@ -133,8 +123,8 @@ export function SuggestedStreamPanel({
<EditSuggestedRuleControls
onSave={onSave}
onAccept={() => reviewSuggestedRule(currentSuggestion.name || partition.name)}
nameError={nameError}
conditionError={conditionError}
isStreamNameValid={isStreamNameValid}
/>
</EuiFlexGroup>
</SelectablePanel>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,25 @@
* 2.0.
*/

import React, { useEffect, useMemo } from 'react';
import { createActorContext, useSelector } from '@xstate5/react';
import type { Condition } from '@kbn/streamlang';
import type { RoutingDefinition } from '@kbn/streams-schema';
import { createConsoleInspector } from '@kbn/xstate-utils';
import { createActorContext, useSelector } from '@xstate5/react';
import { debounce } from 'lodash';
import React, { useEffect, useMemo } from 'react';
import { waitFor } from 'xstate5';
import type { RoutingDefinition } from '@kbn/streams-schema';
import type { Condition } from '@kbn/streamlang';
import {
streamRoutingMachine,
createStreamRoutingMachineImplementations,
} from './stream_routing_state_machine';
import type { StreamRoutingInput, StreamRoutingServiceDependencies } from './types';
import type { PartitionSuggestion } from '../../review_suggestions_form/use_review_suggestions_form';
import type { RoutingDefinitionWithUIAttributes } from '../../types';
import type {
DocumentMatchFilterOptions,
RoutingSamplesActorRef,
RoutingSamplesActorSnapshot,
} from './routing_samples_state_machine';
import type { PartitionSuggestion } from '../../review_suggestions_form/use_review_suggestions_form';
import {
createStreamRoutingMachineImplementations,
streamRoutingMachine,
} from './stream_routing_state_machine';
import type { StreamRoutingInput, StreamRoutingServiceDependencies } from './types';

const consoleInspector = createConsoleInspector();

Expand All @@ -33,17 +34,39 @@ export const useStreamsRoutingActorRef = StreamRoutingContext.useActorRef;

export type StreamRoutingEvents = ReturnType<typeof useStreamRoutingEvents>;

const DEBOUNCE_DELAY = 300;

export const useStreamRoutingEvents = () => {
const service = StreamRoutingContext.useActorRef();

return useMemo(
() => ({
return useMemo(() => {
// Create debounced versions for text input handlers to prevent expensive re-renders
const debouncedChangeRule = debounce(
(routingRule: Partial<RoutingDefinitionWithUIAttributes>) => {
service.send({ type: 'routingRule.change', routingRule });
},
DEBOUNCE_DELAY
);

const debouncedChangeSuggestionName = debounce((name: string) => {
service.send({ type: 'suggestion.changeName', name });
}, DEBOUNCE_DELAY);

return {
cancelChanges: () => {
debouncedChangeRule.cancel();
debouncedChangeSuggestionName.cancel();
service.send({ type: 'routingRule.cancel' });
},
changeRule: (routingRule: Partial<RoutingDefinitionWithUIAttributes>) => {
service.send({ type: 'routingRule.change', routingRule });
},
/**
* Debounced version of changeRule for text input handlers.
* Use this when the onChange is called frequently (e.g., on every keystroke)
* to prevent expensive state machine updates and re-renders.
*/
changeRuleDebounced: debouncedChangeRule,
createNewRule: () => {
service.send({ type: 'routingRule.create' });
},
Expand Down Expand Up @@ -87,15 +110,20 @@ export const useStreamRoutingEvents = () => {
changeSuggestionName: (name: string) => {
service.send({ type: 'suggestion.changeName', name });
},
/**
* Debounced version of changeSuggestionName for text input handlers.
* Use this when the onChange is called frequently (e.g., on every keystroke)
* to prevent expensive state machine updates and re-renders.
*/
changeSuggestionNameDebounced: debouncedChangeSuggestionName,
changeSuggestionCondition: (condition: Condition) => {
service.send({ type: 'suggestion.changeCondition', condition });
},
saveEditedSuggestion: () => {
service.send({ type: 'suggestion.saveSuggestion' });
},
}),
[service]
);
};
}, [service]);
};

export const StreamRoutingContextProvider = ({
Expand Down
Loading