diff --git a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.stories.tsx b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.stories.tsx index 9ca9070fddf1b..11fc157230146 100644 --- a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.stories.tsx +++ b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.stories.tsx @@ -4,12 +4,13 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useState } from 'react'; +import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import type { TemplateDeserialized } from '@kbn/index-management-plugin/common/types'; import { CreateClassicStreamFlyout } from './create_classic_stream_flyout'; +import type { StreamNameValidator } from '../../utils'; const MOCK_TEMPLATES: TemplateDeserialized[] = [ { @@ -52,6 +53,8 @@ const MOCK_TEMPLATES: TemplateDeserialized[] = [ ilmPolicy: { name: '.alerts-ilm-policy' }, indexPatterns: ['logs-apache.access-*'], allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'standard', + composedOf: ['logs@mappings', 'logs@settings', 'ecs@mappings'], _kbnMeta: { type: 'default', hasDatastream: true }, }, { @@ -136,6 +139,14 @@ const MOCK_TEMPLATES: TemplateDeserialized[] = [ ilmPolicy: { name: 'profiling-60-days' }, indexPatterns: ['logs-elastic_agent.apm_server-*'], allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'standard', + composedOf: [ + '.fleet_agent_id_verification-1', + '.fleet_globals-1', + 'logs@mappings', + 'logs@settings', + 'ecs@mappings', + ], _kbnMeta: { type: 'managed', hasDatastream: true }, }, { @@ -163,9 +174,51 @@ const MOCK_TEMPLATES: TemplateDeserialized[] = [ name: 'logs-infinite-retention', indexPatterns: ['logs-infinite-*'], allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'logsdb', lifecycle: { enabled: true, infiniteDataRetention: true }, + composedOf: ['logs@mappings'], _kbnMeta: { type: 'default', hasDatastream: true }, }, + { + name: 'multi-pattern-template', + ilmPolicy: { name: 'logs' }, + indexPatterns: ['*-logs-*-*', 'logs-*-data-*', 'metrics-*'], + allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'lookup', + version: 12, + composedOf: [ + '.fleet_agent_id_verification-1', + '.fleet_globals-1', + 'ecs@mappings', + 'elastic_agent@custom', + 'logs@custom', + 'logs@mappings', + 'logs@settings', + 'logs-elastic_agent.apm_server@custom', + 'logs-elastic_agent.apm_server@package', + ], + _kbnMeta: { type: 'managed', hasDatastream: true }, + }, + { + name: 'very-long-pattern-template', + ilmPolicy: { name: 'logs' }, + indexPatterns: ['*-reallllllllllllllllllly-*-loooooooooooong-*-index-*-name-*', 'short-*'], + allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'lookup', + version: 12, + composedOf: [ + '.fleet_agent_id_verification-1', + '.fleet_globals-1', + 'ecs@mappings', + 'elastic_agent@custom', + 'logs@custom', + 'logs@mappings', + 'logs@settings', + 'logs-elastic_agent.apm_server@custom', + 'logs-elastic_agent.apm_server@package', + ], + _kbnMeta: { type: 'managed', hasDatastream: true }, + }, ]; const meta: Meta = { @@ -176,95 +229,111 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const PrimaryStory = () => { - const [selectedTemplate, setSelectedTemplate] = useState(null); +// Mock validation data for stories +const EXISTING_STREAM_NAMES = [ + 'foo-logs-bar-baz', // Matches *-logs-*-* pattern with foo, bar, baz + 'test-logs-data-stream', + 'logs-myapp-data-production', +]; + +const HIGHER_PRIORITY_PATTERNS = [ + 'foo-logs-bar-*', // Matches foo-logs-bar-anything + 'test-logs-*-stream', +]; + +/** + * Creates a mock validator that wraps validation logic with Storybook action logging. + * You'll see "onValidate" events in the Actions panel whenever validation is triggered. + */ +const createMockValidator = ( + existingNames: string[], + higherPriorityPatterns: string[] +): StreamNameValidator => { + const onValidateAction = action('onValidate'); + + return async (streamName: string) => { + // Log the validation call to Storybook actions panel + onValidateAction(streamName); + + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Check for duplicate + if (existingNames.includes(streamName)) { + const result = { errorType: 'duplicate' as const, conflictingIndexPattern: undefined }; + action('onValidate:result')(result); + return result; + } + + // Check for higher priority pattern conflict + for (const pattern of higherPriorityPatterns) { + const regexPattern = pattern.replace(/\*/g, '.*'); + const regex = new RegExp(`^${regexPattern}$`); + if (regex.test(streamName)) { + const result = { errorType: 'higherPriority' as const, conflictingIndexPattern: pattern }; + action('onValidate:result')(result); + return result; + } + } - return ( + const result = { errorType: null, conflictingIndexPattern: undefined }; + action('onValidate:result')(result); + return result; + }; +}; + +export const Default: Story = { + render: () => ( { - action('onTemplateSelect')(template); - setSelectedTemplate(template); - }} /> - ); + ), }; -export const Primary: Story = { - render: () => , -}; - -const WithPreselectedTemplateStory = () => { - const [selectedTemplate, setSelectedTemplate] = useState('logs-apache.access'); - - return ( +/** + * Tests all validation scenarios. Watch the Actions panel to see when `onValidate` is triggered: + * - Empty fields: Leave any wildcard empty and click Create + * - Duplicate: Select "multi-pattern-template", enter "foo", "bar", "baz" to trigger duplicate validation + * - Higher priority: Enter "foo", "bar", then anything (e.g., "test") to trigger higher priority conflict + */ +export const WithValidation: Story = { + render: () => ( { - action('onTemplateSelect')(template); - setSelectedTemplate(template); - }} + onValidate={createMockValidator(EXISTING_STREAM_NAMES, HIGHER_PRIORITY_PATTERNS)} /> - ); -}; - -export const WithPreselectedTemplate: Story = { - render: () => , + ), }; -const EmptyStateStory = () => { - const [selectedTemplate, setSelectedTemplate] = useState(null); - - return ( +export const EmptyState: Story = { + render: () => ( { - action('onTemplateSelect')(template); - setSelectedTemplate(template); - }} /> - ); + ), }; -export const EmptyState: Story = { - render: () => , -}; - -const ErrorStateStory = () => { - const [selectedTemplate, setSelectedTemplate] = useState(null); - - return ( +export const ErrorState: Story = { + render: () => ( { - action('onTemplateSelect')(template); - setSelectedTemplate(template); - }} hasErrorLoadingTemplates={true} /> - ); -}; - -export const ErrorState: Story = { - render: () => , + ), }; diff --git a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.test.tsx b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.test.tsx index f33723ee27940..7de6101ce7605 100644 --- a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.test.tsx +++ b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; import type { TemplateDeserialized } from '@kbn/index-management-plugin/common/types'; import { CreateClassicStreamFlyout } from './create_classic_stream_flyout'; @@ -15,8 +15,10 @@ const MOCK_TEMPLATES: TemplateDeserialized[] = [ { name: 'template-1', ilmPolicy: { name: '30d' }, - indexPatterns: ['template-1-*'], + indexPatterns: ['logs-template-1-*'], allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'standard', + composedOf: ['logs@mappings', 'logs@settings'], _kbnMeta: { type: 'default', hasDatastream: true }, }, { @@ -24,6 +26,7 @@ const MOCK_TEMPLATES: TemplateDeserialized[] = [ ilmPolicy: { name: '90d' }, indexPatterns: ['template-2-*'], allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'logsdb', _kbnMeta: { type: 'managed', hasDatastream: true }, }, { @@ -33,6 +36,26 @@ const MOCK_TEMPLATES: TemplateDeserialized[] = [ lifecycle: { enabled: true, value: 30, unit: 'd' }, _kbnMeta: { type: 'default', hasDatastream: true }, }, + { + name: 'multi-pattern-template', + ilmPolicy: { name: 'logs' }, + indexPatterns: ['*-logs-*-*', 'logs-*-data-*', 'metrics-*'], + allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'lookup', + version: 12, + composedOf: ['logs@mappings', 'logs@settings'], + _kbnMeta: { type: 'managed', hasDatastream: true }, + }, + { + name: 'very-long-pattern-template', + ilmPolicy: { name: 'logs' }, + indexPatterns: ['*-reallllllllllllllllllly-*-loooooooooooong-*-index-*-name-*', 'short-*'], + allowAutoCreate: 'NO_OVERWRITE', + indexMode: 'lookup', + version: 12, + composedOf: ['logs@mappings', 'logs@settings'], + _kbnMeta: { type: 'managed', hasDatastream: true }, + }, ]; const defaultProps = { @@ -41,8 +64,6 @@ const defaultProps = { onCreateTemplate: jest.fn(), onRetryLoadTemplates: jest.fn(), templates: MOCK_TEMPLATES, - selectedTemplate: null, - onTemplateSelect: jest.fn(), }; const renderFlyout = (props = {}) => { @@ -53,6 +74,19 @@ const renderFlyout = (props = {}) => { ); }; +// Helper to select a template and navigate to second step +const selectTemplateAndGoToStep2 = ( + getByTestId: (id: string) => HTMLElement, + templateName: string +) => { + // Click on a template option + const templateOption = getByTestId(`template-option-${templateName}`); + fireEvent.click(templateOption); + + // Navigate to second step + fireEvent.click(getByTestId('nextButton')); +}; + describe('CreateClassicStreamFlyout', () => { beforeEach(() => { jest.clearAllMocks(); @@ -68,123 +102,12 @@ describe('CreateClassicStreamFlyout', () => { }); }); - describe('empty state', () => { - it('renders empty state when there are no templates', () => { - const onCreateTemplate = jest.fn(); - const { getByText, getByTestId } = renderFlyout({ templates: [], onCreateTemplate }); - - expect(getByText('No index templates detected')).toBeInTheDocument(); - expect( - getByText(/Classic streams require an index template to set their initial settings/i) - ).toBeInTheDocument(); - expect(getByTestId('createTemplateButton')).toBeInTheDocument(); - }); - - it('calls onCreateTemplate when Create index template button is clicked', () => { - const onCreateTemplate = jest.fn(); - const { getByTestId } = renderFlyout({ templates: [], onCreateTemplate }); - - fireEvent.click(getByTestId('createTemplateButton')); - - expect(onCreateTemplate).toHaveBeenCalledTimes(1); - }); - }); - - describe('error state', () => { - it('renders error state when hasErrorLoadingTemplates is true', () => { - const { getByTestId, getByText } = renderFlyout({ - hasErrorLoadingTemplates: true, - }); - - expect(getByTestId('errorLoadingTemplates')).toBeInTheDocument(); - expect(getByText("We couldn't fetch your index templates")).toBeInTheDocument(); - }); - - it('calls onRetryLoadTemplates when Retry button is clicked', () => { - const onRetryLoadTemplates = jest.fn(); - const { getByTestId } = renderFlyout({ - hasErrorLoadingTemplates: true, - onRetryLoadTemplates, - }); - - fireEvent.click(getByTestId('retryLoadTemplatesButton')); - - expect(onRetryLoadTemplates).toHaveBeenCalledTimes(1); - }); - }); - - describe('template selection', () => { - it('renders template search and list', () => { - const { getByTestId } = renderFlyout(); - - expect(getByTestId('templateSearch')).toBeInTheDocument(); - }); - - it('disables next step and Next button when no template is selected', () => { - const { getByTestId } = renderFlyout(); - - const nextStep = getByTestId('createClassicStreamStep-nameAndConfirm'); - expect(nextStep).toBeDisabled(); - - const nextButton = getByTestId('nextButton'); - expect(nextButton).toBeDisabled(); - }); - - it('enables next step and Next button when a template is selected', () => { - const { getByTestId } = renderFlyout({ selectedTemplate: 'template-1' }); - - const nextStep = getByTestId('createClassicStreamStep-nameAndConfirm'); - expect(nextStep).toBeEnabled(); - - const nextButton = getByTestId('nextButton'); - expect(nextButton).toBeEnabled(); - }); - - it('calls onTemplateSelect when a template is selected', () => { - const onTemplateSelect = jest.fn(); - const { getByTestId } = renderFlyout({ onTemplateSelect }); - - // Click on a template option - const templateOption = getByTestId('template-option-template-1'); - fireEvent.click(templateOption); - - expect(onTemplateSelect).toHaveBeenCalledWith('template-1'); - }); - - it('displays ILM badge for templates with ILM policy', () => { - const { getAllByText, getByText } = renderFlyout(); - - // template-1 has ilmPolicy: { name: '30d' }, template-2 has ilmPolicy: { name: '90d' } - // Both should display ILM badge - const ilmBadges = getAllByText('ILM'); - expect(ilmBadges.length).toBe(2); - expect(getByText('90d')).toBeInTheDocument(); - }); - - it('displays lifecycle data retention for templates without ILM policy', () => { - const { getByTestId } = renderFlyout(); - - // template-3 has lifecycle: { enabled: true, value: 30, unit: 'd' } but no ILM - // Should display the retention period in the template option - const templateOption = getByTestId('template-option-template-3'); - expect(templateOption).toBeInTheDocument(); - // The 30d retention should be displayed (same as ILM policy name for template-1, - // but this verifies template-3 option exists and is rendered) - }); - - it('renders all template options including managed templates', () => { - const { getByTestId } = renderFlyout(); - - // Verify all templates are rendered, including managed template-2 - expect(getByTestId('template-option-template-1')).toBeInTheDocument(); - expect(getByTestId('template-option-template-2')).toBeInTheDocument(); - expect(getByTestId('template-option-template-3')).toBeInTheDocument(); - }); - }); - describe('navigation', () => { it('navigates to second step when clicking Next button with selected template', () => { - const { getByTestId, queryByTestId } = renderFlyout({ selectedTemplate: 'template-1' }); + const { getByTestId, queryByTestId } = renderFlyout(); + + // Select a template first + fireEvent.click(getByTestId('template-option-template-1')); // Check that the first step content is rendered expect(getByTestId('selectTemplateStep')).toBeInTheDocument(); @@ -211,10 +134,10 @@ describe('CreateClassicStreamFlyout', () => { }); it('navigates back to first step when clicking Back button', () => { - const { getByTestId, queryByTestId } = renderFlyout({ selectedTemplate: 'template-1' }); + const { getByTestId, queryByTestId } = renderFlyout(); - // Navigate to second step - fireEvent.click(getByTestId('nextButton')); + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); // Verify that the second step content is rendered expect(queryByTestId('selectTemplateStep')).not.toBeInTheDocument(); @@ -255,27 +178,56 @@ describe('CreateClassicStreamFlyout', () => { expect(onClose).toHaveBeenCalledTimes(2); }); - it('calls onCreate when Create button is clicked', () => { + it('calls onCreate with stream name when Create button is clicked and validation passes', async () => { const onCreate = jest.fn(); - const { getByTestId } = renderFlyout({ onCreate, selectedTemplate: 'template-1' }); + const { getByTestId } = renderFlyout({ onCreate }); - // Navigate to second step - fireEvent.click(getByTestId('nextButton')); + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); + + // Fill in the stream name (pattern is logs-template-1-*) + const streamNameInput = getByTestId('streamNameInput-wildcard-0'); + fireEvent.change(streamNameInput, { target: { value: 'mystream' } }); // Click Create button fireEvent.click(getByTestId('createButton')); - expect(onCreate).toHaveBeenCalledTimes(1); + // Wait for validation to complete + await waitFor(() => { + expect(onCreate).toHaveBeenCalledTimes(1); + expect(onCreate).toHaveBeenCalledWith('logs-template-1-mystream'); + }); + }); + + it('does not call onCreate when validation fails (empty wildcard)', async () => { + const onCreate = jest.fn(); + const { getByTestId, getByText } = renderFlyout({ onCreate }); + + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); + + // Don't fill in the stream name (leave wildcard empty) + + // Click Create button + fireEvent.click(getByTestId('createButton')); + + // Wait for validation error to appear + await waitFor(() => { + expect( + getByText(/Please supply a valid text string for all wildcards/i) + ).toBeInTheDocument(); + }); + + expect(onCreate).not.toHaveBeenCalled(); }); it('does not call onCreate or onClose when navigating between steps', () => { const onClose = jest.fn(); const onCreate = jest.fn(); - const { getByTestId } = renderFlyout({ - onCreate, - onClose, - selectedTemplate: 'template-1', - }); + const { getByTestId } = renderFlyout({ onCreate, onClose }); + + // Select template + fireEvent.click(getByTestId('template-option-template-1')); // Navigate forward fireEvent.click(getByTestId('nextButton')); @@ -288,4 +240,334 @@ describe('CreateClassicStreamFlyout', () => { expect(onClose).not.toHaveBeenCalled(); }); }); + + describe('select template step', () => { + it('renders the select template step', () => { + const { getByTestId } = renderFlyout(); + + // Check that the select template step is rendered + expect(getByTestId('selectTemplateStep')).toBeInTheDocument(); + + // Check that the template search is rendered + expect(getByTestId('templateSearch')).toBeInTheDocument(); + + // Check that the template options are rendered + expect(getByTestId('template-option-template-1')).toBeInTheDocument(); + expect(getByTestId('template-option-template-2')).toBeInTheDocument(); + expect(getByTestId('template-option-template-3')).toBeInTheDocument(); + expect(getByTestId('template-option-multi-pattern-template')).toBeInTheDocument(); + expect(getByTestId('template-option-very-long-pattern-template')).toBeInTheDocument(); + }); + + describe('empty state', () => { + it('renders empty state when there are no templates', () => { + const onCreateTemplate = jest.fn(); + const { getByText, getByTestId } = renderFlyout({ templates: [], onCreateTemplate }); + + expect(getByText('No index templates detected')).toBeInTheDocument(); + expect( + getByText(/Classic streams require an index template to set their initial settings/i) + ).toBeInTheDocument(); + expect(getByTestId('createTemplateButton')).toBeInTheDocument(); + }); + + it('calls onCreateTemplate when Create index template button is clicked', () => { + const onCreateTemplate = jest.fn(); + const { getByTestId } = renderFlyout({ templates: [], onCreateTemplate }); + + fireEvent.click(getByTestId('createTemplateButton')); + + expect(onCreateTemplate).toHaveBeenCalledTimes(1); + }); + }); + + describe('error state', () => { + it('renders error state when hasErrorLoadingTemplates is true', () => { + const { getByTestId, getByText } = renderFlyout({ + hasErrorLoadingTemplates: true, + }); + + expect(getByTestId('errorLoadingTemplates')).toBeInTheDocument(); + expect(getByText("We couldn't fetch your index templates")).toBeInTheDocument(); + }); + + it('calls onRetryLoadTemplates when Retry button is clicked', () => { + const onRetryLoadTemplates = jest.fn(); + const { getByTestId } = renderFlyout({ + hasErrorLoadingTemplates: true, + onRetryLoadTemplates, + }); + + fireEvent.click(getByTestId('retryLoadTemplatesButton')); + + expect(onRetryLoadTemplates).toHaveBeenCalledTimes(1); + }); + }); + describe('template selection', () => { + it('disables next step and Next button when no template is selected', () => { + const { getByTestId } = renderFlyout(); + + const nextStep = getByTestId('createClassicStreamStep-nameAndConfirm'); + expect(nextStep).toBeDisabled(); + + const nextButton = getByTestId('nextButton'); + expect(nextButton).toBeDisabled(); + }); + + it('enables next step and Next button when a template is selected', () => { + const { getByTestId } = renderFlyout(); + + // Select a template + fireEvent.click(getByTestId('template-option-template-1')); + + const nextStep = getByTestId('createClassicStreamStep-nameAndConfirm'); + expect(nextStep).toBeEnabled(); + + const nextButton = getByTestId('nextButton'); + expect(nextButton).toBeEnabled(); + }); + + it('displays ILM badge for templates with ILM policy', () => { + const { getAllByText, getByText } = renderFlyout(); + + // template-1 has ilmPolicy: { name: '30d' }, template-2 has ilmPolicy: { name: '90d' } + // Both should display ILM badge + const ilmBadges = getAllByText('ILM'); + expect(ilmBadges.length).toBeGreaterThanOrEqual(2); + expect(getByText('90d')).toBeInTheDocument(); + }); + + it('displays lifecycle data retention for templates without ILM policy', () => { + const { getByTestId } = renderFlyout(); + + // template-3 has lifecycle: { enabled: true, value: 30, unit: 'd' } but no ILM + // Should display the retention period in the template option + const templateOption = getByTestId('template-option-template-3'); + expect(templateOption).toBeInTheDocument(); + }); + + it('renders all template options including managed templates', () => { + const { getByTestId } = renderFlyout(); + + // Verify all templates are rendered, including managed template-2 + expect(getByTestId('template-option-template-1')).toBeInTheDocument(); + expect(getByTestId('template-option-template-2')).toBeInTheDocument(); + expect(getByTestId('template-option-template-3')).toBeInTheDocument(); + }); + }); + }); + + describe('name and confirm step', () => { + it('renders the name and confirm step', () => { + const { getByTestId, getByText } = renderFlyout(); + + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); + + // Check step content is rendered + expect(getByTestId('nameAndConfirmStep')).toBeInTheDocument(); + + // Check section title + expect(getByText('Name classic stream')).toBeInTheDocument(); + }); + + it('displays the index pattern prefix as prepend text', () => { + const { getByTestId, getByText } = renderFlyout(); + + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); + + // Check the wildcard input exists + const wildcardInput = getByTestId('streamNameInput-wildcard-0'); + expect(wildcardInput).toBeInTheDocument(); + + // Check the static prefix is displayed as prepend text + // template-1 has indexPatterns: ['logs-template-1-*'] + expect(getByText('logs-template-1-')).toBeInTheDocument(); + }); + + it('allows editing the stream name input', () => { + const { getByTestId } = renderFlyout(); + + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); + + // Check the editable input + const streamNameInput = getByTestId('streamNameInput-wildcard-0'); + expect(streamNameInput).toBeInTheDocument(); + + // Change the input value + fireEvent.change(streamNameInput, { target: { value: 'my-stream' } }); + expect(streamNameInput).toHaveValue('my-stream'); + }); + + describe('multiple index patterns', () => { + it('shows index pattern selector when template has multiple patterns', () => { + const { getByTestId } = renderFlyout(); + + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'multi-pattern-template'); + + // Should show index pattern selector + expect(getByTestId('indexPatternSelect')).toBeInTheDocument(); + }); + + it('does not show index pattern selector when template has single pattern', () => { + const { getByTestId, queryByTestId } = renderFlyout(); + + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); + + // Should NOT show index pattern selector + expect(queryByTestId('indexPatternSelect')).not.toBeInTheDocument(); + }); + + it('updates stream name input when index pattern is changed', () => { + const { getByTestId, queryByTestId, getByText } = renderFlyout(); + + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'multi-pattern-template'); + + // Default pattern is '*-logs-*-*' which has 3 wildcards + expect(getByTestId('streamNameInput-wildcard-0')).toBeInTheDocument(); + expect(getByTestId('streamNameInput-wildcard-1')).toBeInTheDocument(); + expect(getByTestId('streamNameInput-wildcard-2')).toBeInTheDocument(); + + // Change to 'metrics-*' which has only 1 wildcard + const patternSelect = getByTestId('indexPatternSelect'); + fireEvent.change(patternSelect, { target: { value: 'metrics-*' } }); + + // Should now have only 1 editable input + expect(getByTestId('streamNameInput-wildcard-0')).toBeInTheDocument(); + expect(queryByTestId('streamNameInput-wildcard-1')).not.toBeInTheDocument(); + + // Should have static prefix 'metrics-' as prepend text + expect(getByText('metrics-')).toBeInTheDocument(); + }); + }); + + describe('stream name reset on template change', () => { + it('resets stream name inputs when going back and selecting a different template', () => { + const { getByTestId } = renderFlyout(); + + // Select template-1 and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); + + // Fill in the stream name + const streamNameInput = getByTestId('streamNameInput-wildcard-0'); + fireEvent.change(streamNameInput, { target: { value: 'my-value' } }); + expect(streamNameInput).toHaveValue('my-value'); + + // Go back + fireEvent.click(getByTestId('backButton')); + + // Select a different template + fireEvent.click(getByTestId('template-option-template-2')); + + // Navigate to second step again + fireEvent.click(getByTestId('nextButton')); + + // The input should be reset (empty) + const newInput = getByTestId('streamNameInput-wildcard-0'); + expect(newInput).toHaveValue(''); + }); + }); + + describe('validation', () => { + it('shows validation error when trying to create with empty wildcard', async () => { + const onCreate = jest.fn(); + const { getByTestId, getByText } = renderFlyout({ onCreate }); + + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); + + // Click Create without filling in the wildcard + fireEvent.click(getByTestId('createButton')); + + // Should show validation error + await waitFor(() => { + expect( + getByText(/Please supply a valid text string for all wildcards/i) + ).toBeInTheDocument(); + }); + + // onCreate should not be called + expect(onCreate).not.toHaveBeenCalled(); + }); + + it('calls onValidate when provided and local validation passes', async () => { + const onCreate = jest.fn(); + const onValidate = jest.fn().mockResolvedValue({ errorType: null }); + const { getByTestId } = renderFlyout({ onCreate, onValidate }); + + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); + + // Fill in the stream name + const streamNameInput = getByTestId('streamNameInput-wildcard-0'); + fireEvent.change(streamNameInput, { target: { value: 'mystream' } }); + + // Click Create + fireEvent.click(getByTestId('createButton')); + + // Wait for validation + await waitFor(() => { + expect(onValidate).toHaveBeenCalledWith('logs-template-1-mystream'); + expect(onCreate).toHaveBeenCalledWith('logs-template-1-mystream'); + }); + }); + + it('shows duplicate error from onValidate', async () => { + const onCreate = jest.fn(); + const onValidate = jest.fn().mockResolvedValue({ errorType: 'duplicate' }); + const { getByTestId, getByText } = renderFlyout({ onCreate, onValidate }); + + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); + + // Fill in the stream name + const streamNameInput = getByTestId('streamNameInput-wildcard-0'); + fireEvent.change(streamNameInput, { target: { value: 'existing' } }); + + // Click Create + fireEvent.click(getByTestId('createButton')); + + // Should show duplicate error + await waitFor(() => { + expect(getByText(/This stream name already exists/i)).toBeInTheDocument(); + }); + + // onCreate should not be called + expect(onCreate).not.toHaveBeenCalled(); + }); + + it('shows higher priority error from onValidate', async () => { + const onCreate = jest.fn(); + const onValidate = jest.fn().mockResolvedValue({ + errorType: 'higherPriority', + conflictingIndexPattern: 'logs-*', + }); + const { getByTestId, getByText } = renderFlyout({ onCreate, onValidate }); + + // Select template and navigate to second step + selectTemplateAndGoToStep2(getByTestId, 'template-1'); + + // Fill in the stream name + const streamNameInput = getByTestId('streamNameInput-wildcard-0'); + fireEvent.change(streamNameInput, { target: { value: 'conflict' } }); + + // Click Create + fireEvent.click(getByTestId('createButton')); + + // Should show higher priority error + await waitFor(() => { + expect(getByText(/matches a higher priority index template/i)).toBeInTheDocument(); + expect(getByText('logs-*')).toBeInTheDocument(); + }); + + // onCreate should not be called + expect(onCreate).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.tsx b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.tsx index f1dce4d34b55e..66080d656b66f 100644 --- a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.tsx +++ b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/create_classic_stream_flyout.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback, useEffect } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; import { EuiFlyout, EuiFlyoutBody, @@ -25,7 +26,10 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import type { TemplateDeserialized } from '@kbn/index-management-plugin/common/types'; import { css } from '@emotion/react'; -import { SelectTemplateStep } from './steps'; +import { SelectTemplateStep, NameAndConfirmStep, type ValidationErrorType } from './steps'; +import { validateStreamName, type StreamNameValidator } from '../../utils'; + +const VALIDATION_DEBOUNCE_MS = 300; enum ClassicStreamStep { SELECT_TEMPLATE = 'select_template', @@ -46,14 +50,27 @@ const flyoutBodyStyles = css({ }); interface CreateClassicStreamFlyoutProps { + /** Callback when the flyout is closed */ onClose: () => void; - onCreate: () => void; + /** + * Callback when the stream is created. + * Receives the stream name which can be used to create the classic stream. + */ + onCreate: (streamName: string) => void; + /** Callback to navigate to create template flow */ onCreateTemplate: () => void; + /** Available index templates to select from */ templates: TemplateDeserialized[]; - selectedTemplate: string | null; - onTemplateSelect: (templateName: string | null) => void; + /** Whether there was an error loading templates */ hasErrorLoadingTemplates?: boolean; + /** Callback to retry loading templates */ onRetryLoadTemplates: () => void; + /** + * Async callback to validate the stream name. + * Called after local empty field validation passes. + * Should check for duplicate names and higher priority template conflicts. + */ + onValidate?: StreamNameValidator; } export const CreateClassicStreamFlyout = ({ @@ -61,14 +78,71 @@ export const CreateClassicStreamFlyout = ({ onCreate, onCreateTemplate, templates, - selectedTemplate, - onTemplateSelect, hasErrorLoadingTemplates = false, onRetryLoadTemplates, + onValidate, }: CreateClassicStreamFlyoutProps) => { const [currentStep, setCurrentStep] = useState( ClassicStreamStep.SELECT_TEMPLATE ); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [streamName, setStreamName] = useState(''); + const [selectedIndexPattern, setSelectedIndexPattern] = useState(''); + const [validationError, setValidationError] = useState(null); + const [conflictingIndexPattern, setConflictingIndexPattern] = useState(); + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); + const [isValidating, setIsValidating] = useState(false); + + const selectedTemplateData = templates.find((t) => t.name === selectedTemplate); + + // Run validation and update state, returns true if validation passes + const runValidation = useCallback( + async (name: string): Promise => { + setIsValidating(true); + try { + const result = await validateStreamName(name, onValidate); + setValidationError(result.errorType); + setConflictingIndexPattern(result.conflictingIndexPattern); + return result.errorType === null; + } finally { + setIsValidating(false); + } + }, + [onValidate] + ); + + // Debounced validation - only runs after first submit attempt with an error + // When validation passes, reset to "submit only" mode + useDebounce( + () => { + if (hasAttemptedSubmit && validationError !== null) { + runValidation(streamName).then((isValid) => { + if (isValid) { + // Validation passed, reset to "submit only" mode + setHasAttemptedSubmit(false); + } + }); + } + }, + VALIDATION_DEBOUNCE_MS, + [streamName, hasAttemptedSubmit, validationError, runValidation] + ); + + // Reset stream name and validation when changing templates + useEffect(() => { + setStreamName(''); + setSelectedIndexPattern(''); + setValidationError(null); + setConflictingIndexPattern(undefined); + setHasAttemptedSubmit(false); + }, [selectedTemplate]); + + // Reset validation when changing index patterns within a template + useEffect(() => { + setValidationError(null); + setConflictingIndexPattern(undefined); + setHasAttemptedSubmit(false); + }, [selectedIndexPattern]); const isFirstStep = currentStep === ClassicStreamStep.SELECT_TEMPLATE; const hasNextStep = isFirstStep; @@ -78,6 +152,23 @@ export const CreateClassicStreamFlyout = ({ const goToNextStep = () => setCurrentStep(ClassicStreamStep.NAME_AND_CONFIRM); const goToPreviousStep = () => setCurrentStep(ClassicStreamStep.SELECT_TEMPLATE); + const handleCreate = useCallback(async () => { + setHasAttemptedSubmit(true); + setIsValidating(true); + + try { + const result = await validateStreamName(streamName, onValidate); + setValidationError(result.errorType); + setConflictingIndexPattern(result.conflictingIndexPattern); + + if (result.errorType === null) { + onCreate(streamName); + } + } finally { + setIsValidating(false); + } + }, [streamName, onValidate, onCreate]); + const steps: EuiStepsHorizontalProps['steps'] = useMemo( () => [ { @@ -108,15 +199,28 @@ export const CreateClassicStreamFlyout = ({ ); - case ClassicStreamStep.NAME_AND_CONFIRM: - return
; + case ClassicStreamStep.NAME_AND_CONFIRM: { + if (!selectedTemplateData) { + return null; + } + return ( + + ); + } default: return null; @@ -181,7 +285,12 @@ export const CreateClassicStreamFlyout = ({ /> ) : ( - + void; + onStreamNameChange: (streamName: string) => void; + validationError: ValidationErrorType; + conflictingIndexPattern?: string; +} + +export const NameAndConfirmStep = ({ + template, + selectedIndexPattern, + onIndexPatternChange, + onStreamNameChange, + validationError = null, + conflictingIndexPattern, +}: NameAndConfirmStepProps) => { + const indexPatterns = template.indexPatterns ?? []; + + return ( + + + + ); +}; diff --git a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/steps/name_and_confirm/name_stream_section.tsx b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/steps/name_and_confirm/name_stream_section.tsx new file mode 100644 index 0000000000000..0af164c5cc641 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/steps/name_and_confirm/name_stream_section.tsx @@ -0,0 +1,143 @@ +/* + * 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 { EuiFormRow, EuiPanel, EuiSpacer, EuiTitle, EuiSelect, useEuiTheme } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import type { ValidationErrorType } from '../../../../utils'; +import { StreamNameInput } from '../../../stream_name_input'; + +const getValidationErrorMessage = ( + validationError: ValidationErrorType, + conflictingIndexPattern?: string +) => { + if (validationError === 'empty') { + return i18n.translate( + 'xpack.createClassicStreamFlyout.nameAndConfirmStep.emptyValidationError', + { + defaultMessage: + 'Please supply a valid text string for all wildcards within the selected index pattern.', + } + ); + } + + if (validationError === 'duplicate') { + return i18n.translate( + 'xpack.createClassicStreamFlyout.nameAndConfirmStep.duplicateValidationError', + { + defaultMessage: 'This stream name already exists. Please try a different name.', + } + ); + } + + if (validationError === 'higherPriority' && conflictingIndexPattern) { + return [ + {conflictingIndexPattern}, + }} + />, + ]; + } + + return undefined; +}; + +interface NameStreamSectionProps { + indexPatterns: string[]; + selectedIndexPattern: string; + onIndexPatternChange: (pattern: string) => void; + onStreamNameChange: (streamName: string) => void; + validationError: ValidationErrorType; + conflictingIndexPattern?: string; +} + +export const NameStreamSection = ({ + indexPatterns, + selectedIndexPattern, + onIndexPatternChange, + onStreamNameChange, + validationError, + conflictingIndexPattern, +}: NameStreamSectionProps) => { + const { euiTheme } = useEuiTheme(); + + const hasMultiplePatterns = indexPatterns.length > 1; + const currentPattern = selectedIndexPattern || indexPatterns[0] || ''; + + const panelStyles = css` + padding: ${euiTheme.size.l}; + `; + + return ( + + +

+ +

+
+ + + {/* Index pattern selector (only shown when multiple patterns exist) */} + {hasMultiplePatterns && ( + <> + + ({ + value: pattern, + text: pattern, + }))} + value={currentPattern} + onChange={(e) => onIndexPatternChange(e.target.value)} + data-test-subj="indexPatternSelect" + fullWidth + /> + + + + )} + + {/* Stream name inputs */} + + } + > + + +
+ ); +}; diff --git a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/steps/select_template/select_template_step.tsx b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/steps/select_template/select_template_step.tsx index 239e4173f4bf5..586a9883801a0 100644 --- a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/steps/select_template/select_template_step.tsx +++ b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/create_classic_stream_flyout/steps/select_template/select_template_step.tsx @@ -21,27 +21,10 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { TemplateDeserialized } from '@kbn/index-management-plugin/common/types'; +import { formatDataRetention } from '../../../../utils'; import { ErrorState } from './error_state'; import { EmptyState } from './empty_state'; -const formatDataRetention = (template: TemplateDeserialized): string | undefined => { - const { lifecycle } = template; - - if (!lifecycle?.enabled) { - return undefined; - } - - if (lifecycle.infiniteDataRetention) { - return '∞'; - } - - if (lifecycle.value && lifecycle.unit) { - return `${lifecycle.value}${lifecycle.unit}`; - } - - return undefined; -}; - interface SelectTemplateStepProps { templates: TemplateDeserialized[]; selectedTemplate: string | null; diff --git a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/index.ts b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/index.ts new file mode 100644 index 0000000000000..ec6a507f09143 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from './stream_name_input'; diff --git a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/stream_name_input.stories.tsx b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/stream_name_input.stories.tsx new file mode 100644 index 0000000000000..2d29b8df85c5b --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/stream_name_input.stories.tsx @@ -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, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { EuiFormRow, EuiPanel, EuiSpacer, EuiText, EuiCode } from '@elastic/eui'; + +import type { ValidationErrorType } from '../../utils'; +import { StreamNameInput } from './stream_name_input'; + +const meta: Meta = { + component: StreamNameInput, + title: 'Stream Name Input', + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** + * Wrapper component that shows the generated stream name + */ +const StreamNameInputWithPreview = ({ + indexPattern, + validationError, +}: { + indexPattern: string; + validationError?: ValidationErrorType; +}) => { + const [streamName, setStreamName] = useState(''); + + return ( + <> + + { + setStreamName(name); + action('onChange')(name); + }} + validationError={validationError} + /> + + + + Generated stream name: {streamName || '(empty)'} + + + ); +}; + +/** + * Default story with interactive controls + */ +export const Default: Story = { + args: { + indexPattern: '*-logs-*-*', + validationError: null, + }, + argTypes: { + indexPattern: { + control: 'text', + }, + validationError: { + control: 'select', + options: ['empty', 'duplicate', 'higherPriority', null], + }, + }, + render: (args) => ( + + ), +}; + +/** + * Single wildcard at the end - the most common pattern + */ +export const SingleWildcard: Story = { + render: () => , +}; + +/** + * Multiple wildcards - shows connected input group + */ +export const MultipleWildcards: Story = { + render: () => , +}; + +/** + * Pattern with many wildcards (5+) - tests wrapping behavior + */ +export const ManyWildcards: Story = { + render: () => , +}; + +/** + * Pattern with long static text - tests that labels don't truncate + */ +export const LongStaticSegments: Story = { + render: () => ( + + ), +}; + +/** + * Empty validation error - only empty inputs are highlighted. + * Fill in some inputs and leave others empty to see the difference. + */ +export const EmptyValidationError: Story = { + render: () => ( + + ), +}; + +/** + * Duplicate validation error - all inputs are highlighted + */ +export const DuplicateValidationError: Story = { + render: () => ( + + ), +}; + +/** + * Higher priority validation error - all inputs are highlighted + */ +export const HigherPriorityValidationError: Story = { + render: () => ( + + ), +}; diff --git a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/stream_name_input.test.tsx b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/stream_name_input.test.tsx new file mode 100644 index 0000000000000..500094a86725f --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/stream_name_input.test.tsx @@ -0,0 +1,281 @@ +/* + * 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 { render, fireEvent } from '@testing-library/react'; +import { StreamNameInput } from './stream_name_input'; + +describe('StreamNameInput', () => { + const defaultProps = { + indexPattern: 'logs-*', + onChange: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('single wildcard patterns', () => { + it('renders a single input for pattern with one wildcard', () => { + const { getByTestId } = render(); + + expect(getByTestId('streamNameInput-wildcard-0')).toBeInTheDocument(); + }); + + it('displays static prefix as prepend text', () => { + const { getByText } = render( + + ); + + expect(getByText('logs-apache-')).toBeInTheDocument(); + }); + + it('displays static suffix as append text', () => { + const { getByText } = render( + + ); + + expect(getByText('-logs-default')).toBeInTheDocument(); + }); + + it('calls onChange with stream name when input changes', () => { + const onChange = jest.fn(); + const { getByTestId } = render( + + ); + + const input = getByTestId('streamNameInput-wildcard-0'); + fireEvent.change(input, { target: { value: 'mystream' } }); + + expect(onChange).toHaveBeenCalledWith('logs-mystream'); + }); + + it('keeps wildcard (*) in stream name when input is empty', () => { + const onChange = jest.fn(); + render(); + + // Initial call with empty input should keep the wildcard + expect(onChange).toHaveBeenCalledWith('logs-*'); + }); + }); + + describe('multiple wildcard patterns', () => { + it('renders multiple inputs for patterns with multiple wildcards', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('streamNameInput-wildcard-0')).toBeInTheDocument(); + expect(getByTestId('streamNameInput-wildcard-1')).toBeInTheDocument(); + expect(getByTestId('streamNameInput-wildcard-2')).toBeInTheDocument(); + }); + + it('renders static segments between wildcards as prepend text', () => { + const { getByText } = render( + + ); + + expect(getByText('-logs-')).toBeInTheDocument(); + expect(getByText('-data-')).toBeInTheDocument(); + }); + + it('allows editing each wildcard independently', () => { + const { getByTestId } = render( + + ); + + const input0 = getByTestId('streamNameInput-wildcard-0'); + const input1 = getByTestId('streamNameInput-wildcard-1'); + const input2 = getByTestId('streamNameInput-wildcard-2'); + + fireEvent.change(input0, { target: { value: 'foo' } }); + fireEvent.change(input1, { target: { value: 'bar' } }); + fireEvent.change(input2, { target: { value: 'baz' } }); + + expect(input0).toHaveValue('foo'); + expect(input1).toHaveValue('bar'); + expect(input2).toHaveValue('baz'); + }); + + it('correctly builds stream name when only some wildcards are filled', () => { + const onChange = jest.fn(); + const { getByTestId } = render( + + ); + + // Fill only the second wildcard + const input1 = getByTestId('streamNameInput-wildcard-1'); + fireEvent.change(input1, { target: { value: 'foo' } }); + + // Should keep unfilled wildcards as * + expect(onChange).toHaveBeenLastCalledWith('*-logs-foo-*'); + }); + + it('correctly builds stream name when all wildcards are filled', () => { + const onChange = jest.fn(); + const { getByTestId } = render( + + ); + + fireEvent.change(getByTestId('streamNameInput-wildcard-0'), { + target: { value: 'foo' }, + }); + fireEvent.change(getByTestId('streamNameInput-wildcard-1'), { + target: { value: 'bar' }, + }); + fireEvent.change(getByTestId('streamNameInput-wildcard-2'), { + target: { value: 'baz' }, + }); + + expect(onChange).toHaveBeenLastCalledWith('foo-logs-bar-baz'); + }); + + it('supports patterns with many wildcards (5+)', () => { + const { getByTestId, getByText } = render( + + ); + + // All 5 wildcard inputs should exist + expect(getByTestId('streamNameInput-wildcard-0')).toBeInTheDocument(); + expect(getByTestId('streamNameInput-wildcard-1')).toBeInTheDocument(); + expect(getByTestId('streamNameInput-wildcard-2')).toBeInTheDocument(); + expect(getByTestId('streamNameInput-wildcard-3')).toBeInTheDocument(); + expect(getByTestId('streamNameInput-wildcard-4')).toBeInTheDocument(); + + // Static segments should be visible + expect(getByText('-really-')).toBeInTheDocument(); + expect(getByText('-long-')).toBeInTheDocument(); + expect(getByText('-index-')).toBeInTheDocument(); + expect(getByText('-name-')).toBeInTheDocument(); + }); + }); + + describe('pattern changes', () => { + it('resets input values when pattern changes', () => { + const onChange = jest.fn(); + const { getByTestId, rerender } = render( + + ); + + // Fill in the input + const input = getByTestId('streamNameInput-wildcard-0'); + fireEvent.change(input, { target: { value: 'mystream' } }); + expect(input).toHaveValue('mystream'); + + // Change the pattern + rerender(); + + // Input should be reset + const newInput = getByTestId('streamNameInput-wildcard-0'); + expect(newInput).toHaveValue(''); + }); + + it('updates number of inputs when pattern wildcard count changes', () => { + const { getByTestId, queryByTestId, rerender } = render( + + ); + + // Initially 3 wildcards + expect(getByTestId('streamNameInput-wildcard-0')).toBeInTheDocument(); + expect(getByTestId('streamNameInput-wildcard-1')).toBeInTheDocument(); + expect(getByTestId('streamNameInput-wildcard-2')).toBeInTheDocument(); + + // Change to single wildcard pattern + rerender(); + + // Should now have only 1 wildcard + expect(getByTestId('streamNameInput-wildcard-0')).toBeInTheDocument(); + expect(queryByTestId('streamNameInput-wildcard-1')).not.toBeInTheDocument(); + expect(queryByTestId('streamNameInput-wildcard-2')).not.toBeInTheDocument(); + }); + }); + + describe('validation state', () => { + it('does not show invalid state when validationError is null', () => { + const { getByTestId } = render( + + ); + + const input0 = getByTestId('streamNameInput-wildcard-0'); + const input1 = getByTestId('streamNameInput-wildcard-1'); + + expect(input0).not.toHaveAttribute('aria-invalid', 'true'); + expect(input1).not.toHaveAttribute('aria-invalid', 'true'); + }); + + it('shows invalid state on all inputs when validationError is duplicate', () => { + const { getByTestId } = render( + + ); + + const input0 = getByTestId('streamNameInput-wildcard-0'); + const input1 = getByTestId('streamNameInput-wildcard-1'); + + expect(input0).toHaveAttribute('aria-invalid', 'true'); + expect(input1).toHaveAttribute('aria-invalid', 'true'); + }); + + it('shows invalid state on all inputs when validationError is higherPriority', () => { + const { getByTestId } = render( + + ); + + const input0 = getByTestId('streamNameInput-wildcard-0'); + const input1 = getByTestId('streamNameInput-wildcard-1'); + + expect(input0).toHaveAttribute('aria-invalid', 'true'); + expect(input1).toHaveAttribute('aria-invalid', 'true'); + }); + + it('shows invalid state only on empty inputs when validationError is empty', () => { + const { getByTestId } = render( + + ); + + // Initially all inputs are empty, so all should be invalid + const input0 = getByTestId('streamNameInput-wildcard-0'); + const input1 = getByTestId('streamNameInput-wildcard-1'); + + expect(input0).toHaveAttribute('aria-invalid', 'true'); + expect(input1).toHaveAttribute('aria-invalid', 'true'); + + // Fill in the first input + fireEvent.change(input0, { target: { value: 'filled' } }); + + // Now only the second input should be invalid + expect(input0).not.toHaveAttribute('aria-invalid', 'true'); + expect(input1).toHaveAttribute('aria-invalid', 'true'); + }); + + it('clears invalid state on input when filled (empty validation error)', () => { + const { getByTestId } = render( + + ); + + const input0 = getByTestId('streamNameInput-wildcard-0'); + const input1 = getByTestId('streamNameInput-wildcard-1'); + const input2 = getByTestId('streamNameInput-wildcard-2'); + + // All empty initially + expect(input0).toHaveAttribute('aria-invalid', 'true'); + expect(input1).toHaveAttribute('aria-invalid', 'true'); + expect(input2).toHaveAttribute('aria-invalid', 'true'); + + // Fill in the middle input + fireEvent.change(input1, { target: { value: 'filled' } }); + + // Only first and third should be invalid now + expect(input0).toHaveAttribute('aria-invalid', 'true'); + expect(input1).not.toHaveAttribute('aria-invalid', 'true'); + expect(input2).toHaveAttribute('aria-invalid', 'true'); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/stream_name_input.tsx b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/stream_name_input.tsx new file mode 100644 index 0000000000000..aeaf7081a52b3 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/components/stream_name_input/stream_name_input.tsx @@ -0,0 +1,260 @@ +/* + * 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, useCallback, useEffect } from 'react'; +import { EuiFieldText, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { ValidationErrorType } from '../../utils'; + +/* + * PatternSegment is a segment of the index pattern that is either a static text or a wildcard. + * It is used to create the input groups. + */ +interface PatternSegment { + type: 'static' | 'wildcard'; + value: string; + index?: number; +} + +/** + * Groups segments into input groups where each group has: + * - prepend: static text before the wildcard (if any) + * - wildcardIndex: the index of the wildcard + * - append: static text after the wildcard (if any, and only if it's the last wildcard) + */ +interface InputGroup { + prepend?: string; + wildcardIndex: number; + append?: string; + isFirst: boolean; + isLast: boolean; +} + +const parseIndexPattern = (pattern: string): PatternSegment[] => { + if (!pattern) return []; + + const segments: PatternSegment[] = []; + let currentSegment = ''; + let wildcardIndex = 0; + + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]; + if (char === '*') { + if (currentSegment) { + segments.push({ type: 'static', value: currentSegment }); + currentSegment = ''; + } + segments.push({ type: 'wildcard', value: '*', index: wildcardIndex }); + wildcardIndex++; + } else { + currentSegment += char; + } + } + + if (currentSegment) { + segments.push({ type: 'static', value: currentSegment }); + } + + return segments; +}; + +const createInputGroups = (segments: PatternSegment[]): InputGroup[] => { + const groups: InputGroup[] = []; + let pendingPrepend: string | undefined; + const wildcardCount = segments.filter((s) => s.type === 'wildcard').length; + let currentWildcardNum = 0; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + + if (segment.type === 'static') { + // Check if next segment is a wildcard + const nextSegment = segments[i + 1]; + if (nextSegment?.type === 'wildcard') { + // This static text becomes the prepend for the next wildcard + pendingPrepend = segment.value; + } else { + // This is trailing static text - append to the last group + if (groups.length > 0) { + groups[groups.length - 1].append = segment.value; + } + } + } else if (segment.type === 'wildcard') { + currentWildcardNum++; + groups.push({ + prepend: pendingPrepend, + wildcardIndex: segment.index ?? 0, + isFirst: currentWildcardNum === 1, + isLast: currentWildcardNum === wildcardCount, + }); + pendingPrepend = undefined; + } + } + + return groups; +}; + +const countWildcards = (pattern: string): number => { + return (pattern.match(/\*/g) || []).length; +}; + +const buildStreamName = (pattern: string, parts: string[]): string => { + let partIndex = 0; + return pattern.replace(/\*/g, () => { + // Keep * if the part is empty, so validation can detect unfilled wildcards + const part = parts[partIndex] || '*'; + partIndex++; + return part; + }); +}; + +export interface StreamNameInputProps { + /** The index pattern containing wildcards (e.g., "*-logs-*-*") */ + indexPattern: string; + /** Callback when the stream name changes */ + onChange: (streamName: string) => void; + /** + * Validation error type. When 'empty', only empty inputs are highlighted. + * For other error types, all inputs are highlighted. + */ + validationError?: ValidationErrorType; + /** Test subject prefix for the inputs */ + 'data-test-subj'?: string; +} + +export const StreamNameInput = ({ + indexPattern, + onChange, + validationError = null, + 'data-test-subj': dataTestSubj = 'streamNameInput', +}: StreamNameInputProps) => { + const { euiTheme } = useEuiTheme(); + + const segments = useMemo(() => parseIndexPattern(indexPattern), [indexPattern]); + const inputGroups = useMemo(() => createInputGroups(segments), [segments]); + const wildcardCount = useMemo(() => countWildcards(indexPattern), [indexPattern]); + + // Internal state for wildcard values + const [parts, setParts] = useState(() => Array(wildcardCount).fill('')); + + // Reset parts when index pattern changes + useEffect(() => { + setParts(Array(wildcardCount).fill('')); + }, [indexPattern, wildcardCount]); + + // Update stream name when wildcard values change + useEffect(() => { + const streamName = buildStreamName(indexPattern, parts); + onChange(streamName); + }, [indexPattern, parts, onChange]); + + const handleWildcardChange = useCallback((wildcardIndex: number, newValue: string) => { + setParts((prevParts) => { + const newParts = [...prevParts]; + while (newParts.length <= wildcardIndex) { + newParts.push(''); + } + newParts[wildcardIndex] = newValue; + return newParts; + }); + }, []); + + const hasMultipleWildcards = wildcardCount > 1; + + // Determine if a specific input should be marked as invalid + const isInputInvalid = useCallback( + (wildcardIndex: number): boolean => { + if (!validationError) return false; + if (validationError === 'empty') { + // Only highlight empty inputs + return !parts[wildcardIndex]?.trim(); + } + // For other errors (duplicate, higherPriority), highlight all inputs + return true; + }, + [validationError, parts] + ); + + const getConnectedInputStyles = (isFirst: boolean, isLast: boolean) => { + return css` + flex: 1 1 0%; + + /* Ensure the wildcard input is at least 100px wide */ + .euiFormControlLayout__childrenWrapper { + min-width: 100px; + } + + /* Remove border radius on connected sides */ + .euiFormControlLayout, + .euiFieldText { + ${!isLast ? 'border-top-right-radius: 0 ; border-bottom-right-radius: 0 ;' : ''} + ${!isFirst ? 'border-top-left-radius: 0 ; border-bottom-left-radius: 0 ;' : ''} + } + + /* Prevent truncation on labels */ + .euiFormControlLayout__prepend, + .euiFormControlLayout__append { + max-width: none; + } + `; + }; + + const getInputGroupStyles = () => css` + row-gap: ${euiTheme.size.xs}; + `; + + // For single wildcard, use simple prepend/append + if (!hasMultipleWildcards && inputGroups.length === 1) { + const group = inputGroups[0]; + return ( + handleWildcardChange(group.wildcardIndex, e.target.value)} + placeholder="*" + fullWidth + isInvalid={isInputInvalid(group.wildcardIndex)} + prepend={group.prepend} + append={group.append} + data-test-subj={`${dataTestSubj}-wildcard-${group.wildcardIndex}`} + /> + ); + } + + // For multiple wildcards, use flex layout with prepend/append on each input + return ( + + {inputGroups.map((group, index) => { + const isFirst = index === 0; + const isLast = index === inputGroups.length - 1; + + return ( + + handleWildcardChange(group.wildcardIndex, e.target.value)} + placeholder="*" + fullWidth + isInvalid={isInputInvalid(group.wildcardIndex)} + prepend={group.prepend} + append={group.append} + data-test-subj={`${dataTestSubj}-wildcard-${group.wildcardIndex}`} + /> + + ); + })} + + ); +}; diff --git a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/utils/index.ts b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/utils/index.ts new file mode 100644 index 0000000000000..2478c7bb4f7fa --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/utils/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from './utils'; diff --git a/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/utils/utils.ts b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/utils/utils.ts new file mode 100644 index 0000000000000..bb1491477aa97 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-classic-stream-flyout/src/utils/utils.ts @@ -0,0 +1,87 @@ +/* + * 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 type { TemplateDeserialized } from '@kbn/index-management-plugin/common/types'; + +export const formatDataRetention = (template: TemplateDeserialized): string | undefined => { + const { lifecycle } = template; + + if (!lifecycle?.enabled) { + return undefined; + } + + if (lifecycle.infiniteDataRetention) { + return '∞'; + } + + if (lifecycle.value && lifecycle.unit) { + return `${lifecycle.value}${lifecycle.unit}`; + } + + return undefined; +}; + +/** + * Checks if a stream name has unfilled wildcards (contains *) + */ +export const hasEmptyWildcards = (streamName: string): boolean => { + return streamName.includes('*'); +}; + +/** + * Validation error types for stream name validation + */ +export type ValidationErrorType = 'empty' | 'duplicate' | 'higherPriority' | null; + +/** + * Result from stream name validation + */ +export interface StreamNameValidationResult { + /** The type of error, or null if valid */ + errorType: ValidationErrorType; + /** For higherPriority errors, the conflicting index pattern */ + conflictingIndexPattern?: string; +} + +/** + * Async validator function type for external validation (duplicate/priority checks) + */ +export type StreamNameValidator = (streamName: string) => Promise<{ + errorType: 'duplicate' | 'higherPriority' | null; + conflictingIndexPattern?: string; +}>; + +/** + * Validates a stream name by checking for empty wildcards and running async validation. + * Returns the validation result with error type and optional conflicting pattern. + */ +export const validateStreamName = async ( + streamName: string, + onValidate?: StreamNameValidator +): Promise => { + // First, check for empty wildcards (local validation) + if (hasEmptyWildcards(streamName)) { + return { errorType: 'empty' }; + } + + // Run the external validator if provided + if (onValidate) { + const result = await onValidate(streamName); + + if (result.errorType === 'duplicate') { + return { errorType: 'duplicate' }; + } else if (result.errorType === 'higherPriority') { + return { + errorType: 'higherPriority', + conflictingIndexPattern: result.conflictingIndexPattern, + }; + } + } + + // All validations passed + return { errorType: null }; +};