[Search][Playground] Add connectors to sum models (#180428)

- Add state for connector page to have one source of truth and have
ability to control edit flyout by URL
- Add connectorId route to translate it to state and able easily control
edit flyout by URL
- Use new link in playground

Having a link like
`insightsAndAlerting/triggersActionsConnectors/connectors/:connectorId`
will transformed to
`insightsAndAlerting/triggersActionsConnectors/connectors#?_a=(initialConnector:(actionType:OpenAI,actionTypeId:.gen-ai,compatibility:!.....)`.

@elastic/response-ops could you check is it appropriate changes? If yes
I will proceed with adding tests for it
This commit is contained in:
Yan Savitski 2024-04-12 12:50:31 +02:00 committed by GitHub
parent 518b751beb
commit 0893fe72e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 239 additions and 152 deletions

View file

@ -106,6 +106,9 @@ export const Chat = () => {
borderRight: euiTheme.border.thin, borderRight: euiTheme.border.thin,
paddingTop: euiTheme.size.l, paddingTop: euiTheme.size.l,
paddingBottom: euiTheme.size.l, paddingBottom: euiTheme.size.l,
// don't allow the chat to shrink below 66.6% of the screen
flexBasis: 0,
minWidth: '66.6%',
}} }}
> >
<EuiFlexGroup direction="column" className="eui-fullHeight"> <EuiFlexGroup direction="column" className="eui-fullHeight">
@ -210,7 +213,7 @@ export const Chat = () => {
</EuiFlexGroup> </EuiFlexGroup>
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={1}> <EuiFlexItem grow={1} css={{ flexBasis: 0, minWidth: '33.3%' }}>
<ChatSidebar selectedIndicesCount={selectedIndicesCount} /> <ChatSidebar selectedIndicesCount={selectedIndicesCount} />
</EuiFlexItem> </EuiFlexItem>
</EuiFlexGroup> </EuiFlexGroup>

View file

@ -35,16 +35,12 @@ export const SetUpConnectorPanelForStartChat: React.FC = () => {
return connectors && !isConnectorListLoading ? ( return connectors && !isConnectorListLoading ? (
<> <>
{!!Object.keys(connectors).length && showCallout && ( {!!connectors.length && showCallout && (
<EuiCallOut <EuiCallOut
title={i18n.translate('xpack.searchPlayground.emptyPrompts.setUpConnector.settled', { title={i18n.translate('xpack.searchPlayground.emptyPrompts.setUpConnector.settled', {
defaultMessage: defaultMessage: '{connectorName} connector added',
'{connectorsNames} {count, plural, one {connector} other {connectors}} added',
values: { values: {
connectorsNames: Object.values(connectors) connectorName: connectors[0].title,
.map((connector) => connector.title)
.join(', '),
count: Object.values(connectors).length,
}, },
})} })}
iconType="check" iconType="check"

View file

@ -30,8 +30,20 @@ describe('SummarizationModel', () => {
it('renders correctly with models', () => { it('renders correctly with models', () => {
const models = [ const models = [
{ name: 'Model1', disabled: false, icon: MockIcon, connectorId: 'connector1' }, {
{ name: 'Model2', disabled: true, icon: MockIcon, connectorId: 'connector2' }, name: 'Model1',
disabled: false,
icon: MockIcon,
connectorId: 'connector1',
connectorName: 'nameconnector1',
},
{
name: 'Model2',
disabled: true,
icon: MockIcon,
connectorId: 'connector2',
connectorName: 'nameconnector2',
},
]; ];
const { getByTestId } = render( const { getByTestId } = render(
<SummarizationModel selectedModel={models[1]} models={models} onSelect={jest.fn()} /> <SummarizationModel selectedModel={models[1]} models={models} onSelect={jest.fn()} />

View file

@ -8,14 +8,15 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { import {
EuiButtonIcon,
EuiFlexGroup, EuiFlexGroup,
EuiFlexItem, EuiFlexItem,
EuiFormRow, EuiFormRow,
EuiIcon, EuiIcon,
EuiIconTip, EuiIconTip,
EuiLink,
EuiSuperSelect, EuiSuperSelect,
EuiText, EuiText,
EuiToolTip,
} from '@elastic/eui'; } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
@ -30,31 +31,64 @@ interface SummarizationModelProps {
models: LLMModel[]; models: LLMModel[];
} }
const getOptionValue = (model: LLMModel): string => model.connectorId + model.name;
export const SummarizationModel: React.FC<SummarizationModelProps> = ({ export const SummarizationModel: React.FC<SummarizationModelProps> = ({
selectedModel, selectedModel,
models, models,
onSelect, onSelect,
}) => { }) => {
const managementLink = useManagementLink(); const managementLink = useManagementLink(selectedModel.connectorId);
const onChange = (modelName: string) => { const onChange = (modelValue: string) => {
const model = models.find(({ name }) => name === modelName); const newSelectedModel = models.find((model) => getOptionValue(model) === modelValue);
if (model) { if (newSelectedModel) {
onSelect(model); onSelect(newSelectedModel);
} }
}; };
const modelsOption: Array<EuiSuperSelectOption<string>> = useMemo( const modelsOption: Array<EuiSuperSelectOption<string>> = useMemo(
() => () =>
models.map(({ name, disabled, icon, connectorId }) => ({ models.map((model) => ({
value: name, value: getOptionValue(model),
disabled, disabled: model.disabled,
inputDisplay: ( inputDisplay: (
<EuiFlexGroup alignItems="center"> <EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiIcon type={icon} /> <EuiIcon type={model.icon} />
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem>{name}</EuiFlexItem> <EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
gutterSize="s"
css={{ overflow: 'hidden' }}
>
<EuiText size="s">{model.name}</EuiText>
{model.showConnectorName && model.connectorName && (
<EuiText
size="xs"
color="subdued"
css={{ overflow: 'hidden', textOverflow: 'ellipsis', textWrap: 'nowrap' }}
>
<span title={model.connectorName}>{model.connectorName}</span>
</EuiText>
)}
</EuiFlexGroup>
</EuiFlexGroup>
),
dropdownDisplay: (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type={model.icon} />
</EuiFlexItem>
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiText size="s">{model.name}</EuiText>
{model.showConnectorName && model.connectorName && (
<EuiText size="xs" color="subdued">
{model.connectorName}
</EuiText>
)}
</EuiFlexGroup>
</EuiFlexGroup> </EuiFlexGroup>
), ),
})), })),
@ -63,6 +97,7 @@ export const SummarizationModel: React.FC<SummarizationModelProps> = ({
return ( return (
<EuiFormRow <EuiFormRow
css={{ '.euiFormLabel': { display: 'flex', alignItems: 'center' } }}
label={ label={
<> <>
<FormattedMessage <FormattedMessage
@ -77,20 +112,34 @@ export const SummarizationModel: React.FC<SummarizationModelProps> = ({
</> </>
} }
labelAppend={ labelAppend={
<EuiText size="xs"> <EuiToolTip
<EuiLink target="_blank" href={managementLink} data-test-subj="manageConnectorsLink"> delay="long"
<FormattedMessage content={i18n.translate(
id="xpack.searchPlayground.sidebar.summarizationModel.manageConnectors" 'xpack.searchPlayground.sidebar.summarizationModel.manageConnectorTooltip',
defaultMessage="Manage GenAI connectors" {
defaultMessage: 'Manage connector',
}
)}
>
<EuiButtonIcon
target="_blank"
href={managementLink}
data-test-subj="manageConnectorsLink"
iconType="wrench"
aria-label={i18n.translate(
'xpack.searchPlayground.sidebar.summarizationModel.manageConnectorLink',
{
defaultMessage: 'Manage connector',
}
)}
/> />
</EuiLink> </EuiToolTip>
</EuiText>
} }
> >
<EuiSuperSelect <EuiSuperSelect
data-test-subj="summarizationModelSelect" data-test-subj="summarizationModelSelect"
options={modelsOption} options={modelsOption}
valueOfSelected={selectedModel.name} valueOfSelected={getOptionValue(selectedModel)}
onChange={onChange} onChange={onChange}
/> />
</EuiFormRow> </EuiFormRow>

View file

@ -14,9 +14,7 @@ jest.mock('./use_load_connectors', () => ({
useLoadConnectors: jest.fn(), useLoadConnectors: jest.fn(),
})); }));
const mockConnectors = { const mockConnectors = [{ id: 'connectorId1', title: 'OpenAI Connector', type: LLMs.openai }];
[LLMs.openai]: { id: 'connectorId1', title: 'OpenAI Connector' },
};
const mockUseLoadConnectors = (data: any) => { const mockUseLoadConnectors = (data: any) => {
(useLoadConnectors as jest.Mock).mockReturnValue({ data }); (useLoadConnectors as jest.Mock).mockReturnValue({ data });
}; };
@ -32,57 +30,32 @@ describe('useLLMsModels Hook', () => {
const { result } = renderHook(() => useLLMsModels()); const { result } = renderHook(() => useLLMsModels());
expect(result.current).toEqual([ expect(result.current).toEqual([
{
connectorId: undefined,
disabled: true,
icon: expect.any(Function),
name: 'Azure OpenAI',
value: undefined,
},
{ {
connectorId: 'connectorId1', connectorId: 'connectorId1',
disabled: false, disabled: false,
icon: expect.any(Function), icon: expect.any(Function),
name: 'gpt-3.5-turbo', id: 'connectorId1gpt-3.5-turbo ',
name: 'gpt-3.5-turbo ',
showConnectorName: false,
value: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo',
}, },
{ {
connectorId: 'connectorId1', connectorId: 'connectorId1',
disabled: false, disabled: false,
icon: expect.any(Function), icon: expect.any(Function),
name: 'gpt-4', id: 'connectorId1gpt-4 ',
name: 'gpt-4 ',
showConnectorName: false,
value: 'gpt-4', value: 'gpt-4',
}, },
]); ]);
}); });
it('returns LLMModels as disabled when no connectors are available', () => { it('returns emptyd when connectors not available', () => {
mockUseLoadConnectors({}); mockUseLoadConnectors([]);
const { result } = renderHook(() => useLLMsModels()); const { result } = renderHook(() => useLLMsModels());
expect(result.current).toEqual([ expect(result.current).toEqual([]);
{
connectorId: undefined,
disabled: true,
icon: expect.any(Function),
name: 'Azure OpenAI',
value: undefined,
},
{
connectorId: undefined,
disabled: true,
icon: expect.any(Function),
name: 'gpt-3.5-turbo',
value: 'gpt-3.5-turbo',
},
{
connectorId: undefined,
disabled: true,
icon: expect.any(Function),
name: 'gpt-4',
value: 'gpt-4',
},
]);
}); });
}); });

View file

@ -7,48 +7,82 @@
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { OpenAILogo } from '@kbn/stack-connectors-plugin/public/common'; import { OpenAILogo } from '@kbn/stack-connectors-plugin/public/common';
import { ComponentType } from 'react'; import { ComponentType, useMemo } from 'react';
import { LLMs } from '../../common/types'; import { LLMs } from '../../common/types';
import { LLMModel, SummarizationModelName } from '../types'; import { LLMModel } from '../types';
import { useLoadConnectors } from './use_load_connectors'; import { useLoadConnectors } from './use_load_connectors';
const llmModels: Array<{ const mapLlmToModels: Record<
llm: LLMs; LLMs,
icon: ComponentType;
models: Array<{ label: string; value?: string }>;
}> = [
{ {
llm: LLMs.openai_azure, icon: ComponentType;
getModels: (
connectorName: string,
includeName: boolean
) => Array<{ label: string; value?: string }>;
}
> = {
[LLMs.openai_azure]: {
icon: OpenAILogo, icon: OpenAILogo,
models: [ getModels: (connectorName, includeName) => [
{ {
label: i18n.translate('xpack.searchPlayground.openAIAzureModel', { label: i18n.translate('xpack.searchPlayground.openAIAzureModel', {
defaultMessage: 'Azure OpenAI', defaultMessage: 'Azure OpenAI {name}',
values: { name: includeName ? `(${connectorName})` : '' },
}), }),
}, },
], ],
}, },
{ [LLMs.openai]: {
llm: LLMs.openai,
icon: OpenAILogo, icon: OpenAILogo,
models: Object.values(SummarizationModelName).map((model) => ({ label: model, value: model })), getModels: (connectorName, includeName) =>
['gpt-3.5-turbo', 'gpt-4'].map((model) => ({
label: `${model} ${includeName ? `(${connectorName})` : ''}`,
value: model,
})),
}, },
]; };
export const useLLMsModels = (): LLMModel[] => { export const useLLMsModels = (): LLMModel[] => {
const { data: connectors } = useLoadConnectors(); const { data: connectors } = useLoadConnectors();
return llmModels.reduce<LLMModel[]>( const mapConnectorTypeToCount = useMemo(
(result, { llm, icon, models }) => [ () =>
connectors?.reduce<Partial<Record<LLMs, number>>>(
(result, connector) => ({
...result, ...result,
...models.map(({ label, value }) => ({ [connector.type]: (result[connector.type] || 0) + 1,
}),
{}
),
[connectors]
);
return useMemo(
() =>
connectors?.reduce<LLMModel[]>((result, connector) => {
const llmParams = mapLlmToModels[connector.type];
if (!llmParams) {
return result;
}
const showConnectorName = Number(mapConnectorTypeToCount?.[connector.type]) > 1;
return [
...result,
...llmParams.getModels(connector.name, false).map(({ label, value }) => ({
id: connector?.id + label,
name: label, name: label,
value, value,
icon, connectorName: connector.name,
disabled: !connectors?.[llm], showConnectorName,
connectorId: connectors?.[llm]?.id, icon: llmParams.icon,
disabled: !connector,
connectorId: connector.id,
})), })),
], ];
[] }, []) || [],
[connectors, mapConnectorTypeToCount]
); );
}; };

View file

@ -73,8 +73,8 @@ describe('useLoadConnectors', () => {
const { result, waitForNextUpdate } = renderHook(() => useLoadConnectors()); const { result, waitForNextUpdate } = renderHook(() => useLoadConnectors());
await waitForNextUpdate(); await waitForNextUpdate();
await expect(result.current).resolves.toStrictEqual({ await expect(result.current).resolves.toStrictEqual([
openai: { {
actionTypeId: '.gen-ai', actionTypeId: '.gen-ai',
config: { config: {
apiProvider: 'OpenAI', apiProvider: 'OpenAI',
@ -82,8 +82,9 @@ describe('useLoadConnectors', () => {
id: '1', id: '1',
isMissingSecrets: false, isMissingSecrets: false,
title: 'OpenAI', title: 'OpenAI',
type: 'openai',
}, },
openai_azure: { {
actionTypeId: '.gen-ai', actionTypeId: '.gen-ai',
config: { config: {
apiProvider: 'Azure OpenAI', apiProvider: 'Azure OpenAI',
@ -91,8 +92,9 @@ describe('useLoadConnectors', () => {
id: '3', id: '3',
isMissingSecrets: false, isMissingSecrets: false,
title: 'OpenAI Azure', title: 'OpenAI Azure',
type: 'openai_azure',
}, },
}); ]);
}); });
}); });

View file

@ -27,46 +27,44 @@ type OpenAIConnector = UserConfiguredActionConnector<
Record<string, unknown> Record<string, unknown>
>; >;
const mapLLMToActionParam: Record< const connectorTypeToLLM: Array<{
LLMs,
{
actionId: string; actionId: string;
actionProvider?: string; actionProvider?: string;
match: (connector: ActionConnector) => boolean; match: (connector: ActionConnector) => boolean;
transform: (connector: ActionConnector) => PlaygroundConnector; transform: (connector: ActionConnector) => PlaygroundConnector;
} }> = [
> = { {
[LLMs.openai_azure]: {
actionId: OPENAI_CONNECTOR_ID, actionId: OPENAI_CONNECTOR_ID,
actionProvider: OpenAiProviderType.AzureAi, actionProvider: OpenAiProviderType.AzureAi,
match: (connector) => match: (connector) =>
connector.actionTypeId === OPENAI_CONNECTOR_ID &&
(connector as OpenAIConnector).config.apiProvider === OpenAiProviderType.AzureAi, (connector as OpenAIConnector).config.apiProvider === OpenAiProviderType.AzureAi,
transform: (connector) => ({ transform: (connector) => ({
...connector, ...connector,
title: i18n.translate('xpack.searchPlayground.openAIAzureConnectorTitle', { title: i18n.translate('xpack.searchPlayground.openAIAzureConnectorTitle', {
defaultMessage: 'OpenAI Azure', defaultMessage: 'OpenAI Azure',
}), }),
type: LLMs.openai_azure,
}), }),
}, },
[LLMs.openai]: { {
actionId: OPENAI_CONNECTOR_ID, actionId: OPENAI_CONNECTOR_ID,
match: (connector) => match: (connector) =>
connector.actionTypeId === OPENAI_CONNECTOR_ID &&
(connector as OpenAIConnector).config.apiProvider === OpenAiProviderType.OpenAi, (connector as OpenAIConnector).config.apiProvider === OpenAiProviderType.OpenAi,
transform: (connector) => ({ transform: (connector) => ({
...connector, ...connector,
title: i18n.translate('xpack.searchPlayground.openAIConnectorTitle', { title: i18n.translate('xpack.searchPlayground.openAIConnectorTitle', {
defaultMessage: 'OpenAI', defaultMessage: 'OpenAI',
}), }),
type: LLMs.openai,
}), }),
}, },
}; ];
type PlaygroundConnector = ActionConnector & { title: string }; type PlaygroundConnector = ActionConnector & { title: string; type: LLMs };
export const useLoadConnectors = (): UseQueryResult< export const useLoadConnectors = (): UseQueryResult<PlaygroundConnector[], IHttpFetchError> => {
Record<LLMs, PlaygroundConnector>,
IHttpFetchError
> => {
const { const {
services: { http, notifications }, services: { http, notifications },
} = useKibana(); } = useKibana();
@ -76,19 +74,15 @@ export const useLoadConnectors = (): UseQueryResult<
async () => { async () => {
const queryResult = await loadConnectors({ http }); const queryResult = await loadConnectors({ http });
return Object.entries(mapLLMToActionParam).reduce<Partial<Record<LLMs, PlaygroundConnector>>>( return queryResult.reduce<PlaygroundConnector[]>((result, connector) => {
(result, [llm, { actionId, match, transform }]) => { const { transform } = connectorTypeToLLM.find(({ match }) => match(connector)) || {};
const targetConnector = queryResult.find(
(connector) =>
!connector.isMissingSecrets &&
connector.actionTypeId === actionId &&
(match?.(connector) ?? true)
);
return targetConnector ? { ...result, [llm]: transform(targetConnector) } : result; if (!connector.isMissingSecrets && !!transform) {
}, return [...result, transform(connector)];
{} }
);
return result;
}, []);
}, },
{ {
retry: false, retry: false,

View file

@ -40,13 +40,14 @@ describe('useManagementLink Hook', () => {
const expectedUrl = const expectedUrl =
'http://localhost:5601/app/management/insightsAndAlerting/triggersActionsConnectors'; 'http://localhost:5601/app/management/insightsAndAlerting/triggersActionsConnectors';
mockGetUrl.mockResolvedValue(expectedUrl); mockGetUrl.mockResolvedValue(expectedUrl);
const { result, waitForNextUpdate } = renderHook(() => useManagementLink()); const connectorId = 'test-connector-id';
const { result, waitForNextUpdate } = renderHook(() => useManagementLink(connectorId));
await waitForNextUpdate(); await waitForNextUpdate();
expect(result.current).toBe(expectedUrl); expect(result.current).toBe(expectedUrl);
expect(mockGetUrl).toHaveBeenCalledWith({ expect(mockGetUrl).toHaveBeenCalledWith({
sectionId: 'insightsAndAlerting', sectionId: 'insightsAndAlerting',
appId: 'triggersActionsConnectors', appId: 'triggersActionsConnectors/connectors/test-connector-id',
}); });
}); });
@ -62,8 +63,8 @@ describe('useManagementLink Hook', () => {
}, },
}, },
}); });
const connectorId = 'test-connector-id';
const { result } = renderHook(() => useManagementLink()); const { result } = renderHook(() => useManagementLink(connectorId));
expect(result.current).toBe(''); expect(result.current).toBe('');
}); });

View file

@ -8,7 +8,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useKibana } from './use_kibana'; import { useKibana } from './use_kibana';
export const useManagementLink = () => { export const useManagementLink = (connectorId: string) => {
const { const {
services: { share }, services: { share },
} = useKibana(); } = useKibana();
@ -21,12 +21,12 @@ export const useManagementLink = () => {
const getLink = async () => { const getLink = async () => {
const link = await managementLocator?.getUrl({ const link = await managementLocator?.getUrl({
sectionId: 'insightsAndAlerting', sectionId: 'insightsAndAlerting',
appId: 'triggersActionsConnectors', appId: `triggersActionsConnectors/connectors/${connectorId}`,
}); });
setManagementLink(link || ''); setManagementLink(link || '');
}; };
getLink(); getLink();
}, [managementLocator]); }, [managementLocator, connectorId]);
return managementLink; return managementLink;
}; };

View file

@ -104,11 +104,6 @@ export interface AIMessage extends Message {
retrievalDocs: Doc[]; retrievalDocs: Doc[];
} }
export enum SummarizationModelName {
gpt3_5_turbo = 'gpt-3.5-turbo',
gpt_4 = 'gpt-4',
}
export interface ElasticsearchIndex { export interface ElasticsearchIndex {
count: number; // Elasticsearch _count count: number; // Elasticsearch _count
has_in_progress_syncs?: boolean; // these default to false if not a connector or crawler has_in_progress_syncs?: boolean; // these default to false if not a connector or crawler
@ -194,7 +189,9 @@ export interface UseChatHelpers {
export interface LLMModel { export interface LLMModel {
name: string; name: string;
value?: string; value?: string;
showConnectorName?: boolean;
connectorId: string;
connectorName: string;
icon: ComponentType; icon: ComponentType;
disabled: boolean; disabled: boolean;
connectorId?: string;
} }

View file

@ -22,6 +22,7 @@ export type Section = 'connectors' | 'rules' | 'alerts' | 'logs';
export const routeToHome = `/`; export const routeToHome = `/`;
export const routeToConnectors = `/connectors`; export const routeToConnectors = `/connectors`;
export const routeToConnectorEdit = `/connectors/:connectorId`;
export const routeToRules = `/rules`; export const routeToRules = `/rules`;
export const routeToLogs = `/logs`; export const routeToLogs = `/logs`;
export const legacyRouteToAlerts = `/alerts`; export const legacyRouteToAlerts = `/alerts`;

View file

@ -12,7 +12,7 @@ import { Routes, Route } from '@kbn/shared-ux-router';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiButtonEmpty, EuiPageHeader, EuiPageTemplate } from '@elastic/eui'; import { EuiSpacer, EuiButtonEmpty, EuiPageHeader, EuiPageTemplate } from '@elastic/eui';
import { routeToConnectors, routeToLogs, Section } from '../../../constants'; import { routeToConnectorEdit, routeToConnectors, routeToLogs, Section } from '../../../constants';
import { getAlertingSectionBreadcrumb } from '../../../lib/breadcrumb'; import { getAlertingSectionBreadcrumb } from '../../../lib/breadcrumb';
import { getCurrentDocTitle } from '../../../lib/doc_title'; import { getCurrentDocTitle } from '../../../lib/doc_title';
import { suspendedComponentWithProps } from '../../../lib/suspended_component_with_props'; import { suspendedComponentWithProps } from '../../../lib/suspended_component_with_props';
@ -126,7 +126,7 @@ export const ActionsConnectorsHome: React.FunctionComponent<RouteComponentProps<
<Route exact path={routeToLogs} component={renderLogsList} /> <Route exact path={routeToLogs} component={renderLogsList} />
<Route <Route
exact exact
path={routeToConnectors} path={[routeToConnectors, routeToConnectorEdit]}
component={suspendedComponentWithProps(ConnectorsList, 'xl')} component={suspendedComponentWithProps(ConnectorsList, 'xl')}
/> />
</Routes> </Routes>

View file

@ -29,6 +29,15 @@ const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const actionTypeRegistry = actionTypeRegistryMock.create(); const actionTypeRegistry = actionTypeRegistryMock.create();
const mocks = coreMock.createSetup(); const mocks = coreMock.createSetup();
const { loadAllActions, loadActionTypes } = jest.requireMock('../../../lib/action_connector_api'); const { loadAllActions, loadActionTypes } = jest.requireMock('../../../lib/action_connector_api');
const mockGetParams = jest.fn().mockReturnValue({});
const mockGetLocation = jest.fn().mockReturnValue({ search: '' });
const mockGetHistory = jest.fn().mockReturnValue({ push: jest.fn(), createHref: jest.fn() });
jest.mock('react-router-dom', () => ({
useParams: () => mockGetParams(),
useLocation: () => mockGetLocation(),
useHistory: () => mockGetHistory(),
}));
describe('actions_connectors_list', () => { describe('actions_connectors_list', () => {
describe('component empty', () => { describe('component empty', () => {

View file

@ -28,6 +28,7 @@ import { omit } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { withTheme, EuiTheme } from '@kbn/kibana-react-plugin/common'; import { withTheme, EuiTheme } from '@kbn/kibana-react-plugin/common';
import { getConnectorCompatibility } from '@kbn/actions-plugin/common'; import { getConnectorCompatibility } from '@kbn/actions-plugin/common';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { loadAllActions, loadActionTypes, deleteActions } from '../../../lib/action_connector_api'; import { loadAllActions, loadActionTypes, deleteActions } from '../../../lib/action_connector_api';
import { import {
hasDeleteActionsCapability, hasDeleteActionsCapability,
@ -54,6 +55,13 @@ import { CreateConnectorFlyout } from '../../action_connector_form/create_connec
import { EditConnectorFlyout } from '../../action_connector_form/edit_connector_flyout'; import { EditConnectorFlyout } from '../../action_connector_form/edit_connector_flyout';
import { getAlertingSectionBreadcrumb } from '../../../lib/breadcrumb'; import { getAlertingSectionBreadcrumb } from '../../../lib/breadcrumb';
import { getCurrentDocTitle } from '../../../lib/doc_title'; import { getCurrentDocTitle } from '../../../lib/doc_title';
import { routeToConnectors } from '../../../constants';
interface EditConnectorProps {
initialConnector?: ActionConnector;
tab?: EditConnectorTabs;
isFix?: boolean;
}
const ConnectorIconTipWithSpacing = withTheme(({ theme }: { theme: EuiTheme }) => { const ConnectorIconTipWithSpacing = withTheme(({ theme }: { theme: EuiTheme }) => {
return ( return (
@ -89,6 +97,9 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
chrome, chrome,
docLinks, docLinks,
} = useKibana().services; } = useKibana().services;
const { connectorId } = useParams<{ connectorId?: string }>();
const history = useHistory();
const location = useLocation();
const canDelete = hasDeleteActionsCapability(capabilities); const canDelete = hasDeleteActionsCapability(capabilities);
const canSave = hasSaveActionsCapability(capabilities); const canSave = hasSaveActionsCapability(capabilities);
@ -97,13 +108,9 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
const [pageIndex, setPageIndex] = useState<number>(0); const [pageIndex, setPageIndex] = useState<number>(0);
const [selectedItems, setSelectedItems] = useState<ActionConnectorTableItem[]>([]); const [selectedItems, setSelectedItems] = useState<ActionConnectorTableItem[]>([]);
const [isLoadingActionTypes, setIsLoadingActionTypes] = useState<boolean>(false); const [isLoadingActionTypes, setIsLoadingActionTypes] = useState<boolean>(false);
const [isLoadingActions, setIsLoadingActions] = useState<boolean>(false); const [isLoadingActions, setIsLoadingActions] = useState<boolean>(true);
const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false); const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false);
const [editConnectorProps, setEditConnectorProps] = useState<{ const [editConnectorProps, setEditConnectorProps] = useState<EditConnectorProps>({});
initialConnector?: ActionConnector;
tab?: EditConnectorTabs;
isFix?: boolean;
}>({});
const [connectorsToDelete, setConnectorsToDelete] = useState<string[]>([]); const [connectorsToDelete, setConnectorsToDelete] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
loadActions(); loadActions();
@ -164,6 +171,19 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
: []; : [];
useEffect(() => {
if (connectorId && !isLoadingActions) {
const connector = actions.find((action) => action.id === connectorId);
if (connector) {
editItem(connector, EditConnectorTabs.Configuration);
}
const linkToConnectors = history.createHref({ pathname: routeToConnectors });
window.history.replaceState(null, '', linkToConnectors);
}
}, [actions, connectorId, history, isLoadingActions, location]);
function setDeleteConnectorWarning(connectors: string[]) { function setDeleteConnectorWarning(connectors: string[]) {
const show = connectors.some((c) => { const show = connectors.some((c) => {
const action = actions.find((a) => a.id === c); const action = actions.find((a) => a.id === c);
@ -197,11 +217,7 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
} }
} }
async function editItem( function editItem(actionConnector: ActionConnector, tab: EditConnectorTabs, isFix?: boolean) {
actionConnector: ActionConnector,
tab: EditConnectorTabs,
isFix?: boolean
) {
setEditConnectorProps({ initialConnector: actionConnector, tab, isFix: isFix ?? false }); setEditConnectorProps({ initialConnector: actionConnector, tab, isFix: isFix ?? false });
} }