Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -457,3 +457,62 @@ export const Narrow: Story = {
},
decorators: [(story) => <div style={{ maxWidth: 900 }}>{story()}</div>],
};

export const ConnectorSubtypeDeduplication: Story = {
args: {
...Default.args,
items: [
{
id: 'wf-dedup-1',
name: '1Password Secret Rotation',
description:
'Fetches multiple secrets from 1Password, validates them, and rotates expired ones',
enabled: true,
valid: true,
createdAt: hoursAgo(24),
tags: ['1password', 'secrets'],
history: [],
definition: {
version: '1',
name: '1Password Secret Rotation',
enabled: true,
tags: ['1password', 'secrets'],
steps: [
{ name: 'get_secret', type: '.one_password.get_item' },
{ name: 'list_secrets', type: '.one_password.get_items' },
{ name: 'check_expiry', type: 'if', condition: 'true', steps: [] },
{ name: 'rotate', type: '.one_password.update_item' },
{ name: 'notify', type: 'slack_api' },
],
triggers: [{ type: 'scheduled', with: { every: '1d' } }],
},
},
{
id: 'wf-dedup-2',
name: 'Multi-ES Pipeline',
description: 'Queries multiple indices and bulk-indexes aggregated results',
enabled: true,
valid: true,
createdAt: hoursAgo(48),
tags: ['elasticsearch'],
history: [],
definition: {
version: '1',
name: 'Multi-ES Pipeline',
enabled: true,
tags: ['elasticsearch'],
steps: [
{ name: 'search_logs', type: 'elasticsearch.search' },
{ name: 'search_metrics', type: 'elasticsearch.search' },
{ name: 'transform', type: 'data.set' },
{ name: 'bulk_index', type: 'elasticsearch.bulk' },
{ name: 'create_case', type: 'kibana.createCase' },
{ name: 'update_case', type: 'kibana.updateCase' },
],
triggers: [{ type: 'scheduled', with: { every: '5m' } }],
},
},
],
total: 2,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ jest.mock('@kbn/workflows', () => ({
getBuiltInStepDefinition: jest.fn(() => undefined),
}));

jest.mock('../../shared/ui/step_icons/get_base_connector_type', () => ({
getBaseConnectorType: jest.fn((type: string) => {
if (type.startsWith('elasticsearch.')) {
return 'elasticsearch';
}
if (type.startsWith('kibana.')) {
return 'kibana';
}
const normalized = type.startsWith('.') ? type.slice(1) : type;
if (normalized.includes('.')) {
return normalized.split('.')[0];
}
return normalized;
}),
}));

jest.mock('../../shared/ui/step_icons/get_step_icon_type', () => ({
getStepIconType: jest.fn(() => 'globe'),
}));
Expand Down Expand Up @@ -104,4 +120,17 @@ describe('WorkflowsStepTypesList', () => {
expect(httpIcons.length).toBe(1);
expect(slackIcons.length).toBe(1);
});

it('deduplicates by base connector type', () => {
const steps = [
{ name: 'step1', type: 'elasticsearch.search' },
{ name: 'step2', type: 'elasticsearch.bulk' },
{ name: 'step3', type: 'slack' },
];
render(<WorkflowsStepTypesList steps={steps} />);
const esIcons = document.querySelectorAll('[title="elasticsearch"]');
const slackIcons = document.querySelectorAll('[title="slack"]');
expect(esIcons.length).toBe(1);
expect(slackIcons.length).toBe(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
import type { Step } from '@kbn/workflows';
import { collectAllSteps, getBuiltInStepDefinition } from '@kbn/workflows';
import { getBaseConnectorType } from '../../shared/ui/step_icons/get_base_connector_type';
import { getStepIconType } from '../../shared/ui/step_icons/get_step_icon_type';
import { PopoverItems } from '../worflows_triggers_list/popover_items';

Expand Down Expand Up @@ -73,28 +74,30 @@ export const WorkflowsStepTypesList = ({ steps }: WorkflowsStepTypesListProps) =
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState<number | null>(null);

const uniqueStepTypes = useMemo(() => {
const uniqueBaseTypes = useMemo(() => {
const allSteps = collectAllSteps(steps);
const seen = new Set<string>();
return allSteps.filter((step) => {
if (seen.has(step.type)) {
return false;
const result: string[] = [];
for (const step of allSteps) {
const baseType = getBaseConnectorType(step.type);
if (!seen.has(baseType)) {
seen.add(baseType);
result.push(baseType);
}
seen.add(step.type);
return true;
});
}
return result;
}, [steps]);

useLayoutEffect(() => {
const container = containerRef.current;
if (!container || uniqueStepTypes.length === 0) {
if (!container || uniqueBaseTypes.length === 0) {
return;
}

const update = () => {
const width = container.getBoundingClientRect().width;
if (width > 0) {
setVisibleCount(calculateVisibleIconsCount(width, uniqueStepTypes.length));
setVisibleCount(calculateVisibleIconsCount(width, uniqueBaseTypes.length));
}
};

Expand All @@ -106,15 +109,15 @@ export const WorkflowsStepTypesList = ({ steps }: WorkflowsStepTypesListProps) =
cancelAnimationFrame(rafId);
observer.disconnect();
};
}, [uniqueStepTypes.length]);
}, [uniqueBaseTypes.length]);

if (uniqueStepTypes.length === 0) {
if (uniqueBaseTypes.length === 0) {
return null;
}

const displayCount = visibleCount ?? uniqueStepTypes.length;
const visibleItems = uniqueStepTypes.slice(0, displayCount);
const hasOverflow = displayCount < uniqueStepTypes.length;
const displayCount = visibleCount ?? uniqueBaseTypes.length;
const visibleItems = uniqueBaseTypes.slice(0, displayCount);
const hasOverflow = displayCount < uniqueBaseTypes.length;

return (
<div ref={containerRef} css={stepTypesListStyles.container}>
Expand All @@ -125,40 +128,36 @@ export const WorkflowsStepTypesList = ({ steps }: WorkflowsStepTypesListProps) =
wrap={false}
css={stepTypesListStyles.iconGroup}
>
{visibleItems.map((step) => (
<EuiFlexItem grow={false} key={step.type}>
<EuiIcon
type={getStepIconType(step.type)}
size="m"
title={getStepTypeLabel(step.type)}
/>
{visibleItems.map((baseType) => (
<EuiFlexItem grow={false} key={baseType}>
<EuiIcon type={getStepIconType(baseType)} size="m" title={getStepTypeLabel(baseType)} />
</EuiFlexItem>
))}
{hasOverflow && (
<EuiFlexItem grow={false}>
<PopoverItems
items={uniqueStepTypes}
items={uniqueBaseTypes}
popoverTitle={i18n.translate('workflows.stepTypesList.title', {
defaultMessage: 'Step types',
})}
popoverButtonTitle={
displayCount === 0
? uniqueStepTypes.length.toString()
: `+${(uniqueStepTypes.length - displayCount).toString()}`
? uniqueBaseTypes.length.toString()
: `+${(uniqueBaseTypes.length - displayCount).toString()}`
}
dataTestPrefix="stepTypes"
renderItem={(step, idx) => (
renderItem={(baseType, idx) => (
<EuiFlexGroup
key={`${step.type}-${idx}`}
key={`${baseType}-${idx}`}
alignItems="center"
gutterSize="s"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiIcon type={getStepIconType(step.type)} size="s" aria-hidden={true} />
<EuiIcon type={getStepIconType(baseType)} size="s" aria-hidden={true} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">{getStepTypeLabel(step.type)}</EuiText>
<EuiText size="s">{getStepTypeLabel(baseType)}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
)}
Expand Down
Loading