[Actions][Jira] Set parent issue for Sub-task issue type (#78772)

This commit is contained in:
Christos Nasikas 2020-10-01 09:34:25 +03:00 committed by GitHub
parent ec9d220b3c
commit 3d9ea52803
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 733 additions and 24 deletions

View file

@ -69,6 +69,8 @@ Priority:: The priority of the incident.
Labels:: The labels of the incident.
Title:: A title for the issue, used for searching the contents of the knowledge base.
Description:: The details about the incident.
Parent:: The parent issue id or key. Only for `Sub-task` issue types.
Priority:: The priority of the incident.
Additional comments:: Additional information for the client, such as how to troubleshoot the issue.
[[configuring-jira]]

View file

@ -274,12 +274,12 @@ Running the action by scheduling a task means that we will no longer have a user
The following table describes the properties of the `options` object.
| Property | Description | Type |
| -------- | ------------------------------------------------------------------------------------------------------ | ------ |
| id | The id of the action you want to execute. | string |
| params | The `params` value to give the action type executor. | object |
| spaceId | The space id the action is within. | string |
| apiKey | The Elasticsearch API key to use for context. (Note: only required and used when security is enabled). | string |
| Property | Description | Type |
| -------- | ------------------------------------------------------------------------------------------------------ | ---------------- |
| id | The id of the action you want to execute. | string |
| params | The `params` value to give the action type executor. | object |
| spaceId | The space id the action is within. | string |
| apiKey | The Elasticsearch API key to use for context. (Note: only required and used when security is enabled). | string |
| source | The source of the execution, either an HTTP request or a reference to a Saved Object. | object, optional |
## Example
@ -308,11 +308,11 @@ This api runs the action and asynchronously returns the result of running the ac
The following table describes the properties of the `options` object.
| Property | Description | Type |
| -------- | ------------------------------------------------------------------------------------ | ------ |
| id | The id of the action you want to execute. | string |
| params | The `params` value to give the action type executor. | object |
| source | The source of the execution, either an HTTP request or a reference to a Saved Object.| object, optional |
| Property | Description | Type |
| -------- | ------------------------------------------------------------------------------------- | ---------------- |
| id | The id of the action you want to execute. | string |
| params | The `params` value to give the action type executor. | object |
| source | The source of the execution, either an HTTP request or a reference to a Saved Object. | object, optional |
## Example
@ -330,7 +330,7 @@ const result = await actionsClient.execute({
},
source: asSavedObjectExecutionSource({
id: '573891ae-8c48-49cb-a197-0cd5ec34a88b',
type: 'alert'
type: 'alert',
}),
});
```
@ -620,6 +620,7 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla
| issueType | The id of the issue type in Jira. | string _(optional)_ |
| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ |
| labels | An array of labels. | string[] _(optional)_ |
| parent | The parent issue id or key. Only for `Sub-task` issue types. | string _(optional)_ |
| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ |
#### `subActionParams (issueTypes)`

View file

@ -93,6 +93,7 @@ describe('api', () => {
issueType: '10006',
labels: ['kibana', 'elastic'],
priority: 'High',
parent: null,
},
});
expect(externalService.updateIncident).not.toHaveBeenCalled();
@ -252,6 +253,7 @@ describe('api', () => {
issueType: '10006',
labels: ['kibana', 'elastic'],
priority: 'High',
parent: null,
},
});
expect(externalService.createIncident).not.toHaveBeenCalled();
@ -380,6 +382,36 @@ describe('api', () => {
});
});
describe('getIssues', () => {
test('it returns the issues correctly', async () => {
const res = await api.issues({
externalService,
params: { title: 'Title test' },
});
expect(res).toEqual([
{
id: '10267',
key: 'RJ-107',
title: 'Test title',
},
]);
});
});
describe('getIssue', () => {
test('it returns the issue correctly', async () => {
const res = await api.issue({
externalService,
params: { id: 'RJ-107' },
});
expect(res).toEqual({
id: '10267',
key: 'RJ-107',
title: 'Test title',
});
});
});
describe('mapping variations', () => {
test('overwrite & append', async () => {
mapping.set('title', {

View file

@ -13,8 +13,10 @@ import {
Incident,
GetFieldsByIssueTypeHandlerArgs,
GetIssueTypesHandlerArgs,
GetIssuesHandlerArgs,
PushToServiceApiParams,
PushToServiceResponse,
GetIssueHandlerArgs,
} from './types';
// TODO: to remove, need to support Case
@ -46,6 +48,18 @@ const getFieldsByIssueTypeHandler = async ({
return res;
};
const getIssuesHandler = async ({ externalService, params }: GetIssuesHandlerArgs) => {
const { title } = params;
const res = await externalService.getIssues(title);
return res;
};
const getIssueHandler = async ({ externalService, params }: GetIssueHandlerArgs) => {
const { id } = params;
const res = await externalService.getIssue(id);
return res;
};
const pushToServiceHandler = async ({
externalService,
mapping,
@ -83,8 +97,8 @@ const pushToServiceHandler = async ({
currentIncident,
});
} else {
const { title, description, priority, labels, issueType } = params;
incident = { summary: title, description, priority, labels, issueType };
const { title, description, priority, labels, issueType, parent } = params;
incident = { summary: title, description, priority, labels, issueType, parent };
}
if (externalId != null) {
@ -134,4 +148,6 @@ export const api: ExternalServiceApi = {
getIncident: getIncidentHandler,
issueTypes: getIssueTypesHandler,
fieldsByIssueType: getFieldsByIssueTypeHandler,
issues: getIssuesHandler,
issue: getIssueHandler,
};

View file

@ -25,6 +25,8 @@ import {
JiraExecutorResultData,
ExecutorSubActionGetFieldsByIssueTypeParams,
ExecutorSubActionGetIssueTypesParams,
ExecutorSubActionGetIssuesParams,
ExecutorSubActionGetIssueParams,
} from './types';
import * as i18n from './translations';
import { Logger } from '../../../../../../src/core/server';
@ -37,7 +39,13 @@ interface GetActionTypeParams {
configurationUtilities: ActionsConfigurationUtilities;
}
const supportedSubActions: string[] = ['pushToService', 'issueTypes', 'fieldsByIssueType'];
const supportedSubActions: string[] = [
'pushToService',
'issueTypes',
'fieldsByIssueType',
'issues',
'issue',
];
// action type definition
export function getActionType(
@ -137,5 +145,21 @@ async function executor(
});
}
if (subAction === 'issues') {
const getIssuesParams = subActionParams as ExecutorSubActionGetIssuesParams;
data = await api.issues({
externalService,
params: getIssuesParams,
});
}
if (subAction === 'issue') {
const getIssueParams = subActionParams as ExecutorSubActionGetIssueParams;
data = await api.issue({
externalService,
params: getIssueParams,
});
}
return { status: 'ok', data: data ?? {}, actionId };
}

View file

@ -61,6 +61,18 @@ const createMock = (): jest.Mocked<ExternalService> => {
defaultValue: { name: 'Medium', id: '3' },
},
})),
getIssues: jest.fn().mockImplementation(() => [
{
id: '10267',
key: 'RJ-107',
title: 'Test title',
},
]),
getIssue: jest.fn().mockImplementation(() => ({
id: '10267',
key: 'RJ-107',
title: 'Test title',
})),
};
service.createComment.mockImplementationOnce(() =>
@ -120,6 +132,7 @@ const executorParams: ExecutorSubActionPushParams = {
labels: ['kibana', 'elastic'],
priority: 'High',
issueType: '10006',
parent: null,
comments: [
{
commentId: 'case-comment-1',

View file

@ -44,6 +44,7 @@ export const ExecutorSubActionPushParamsSchema = schema.object({
issueType: schema.nullable(schema.string()),
priority: schema.nullable(schema.string()),
labels: schema.nullable(schema.arrayOf(schema.string())),
parent: schema.nullable(schema.string()),
// TODO: modify later to string[] - need for support Case schema
comments: schema.nullable(schema.arrayOf(CommentSchema)),
...EntityInformation,
@ -60,6 +61,8 @@ export const ExecutorSubActionGetIssueTypesParamsSchema = schema.object({});
export const ExecutorSubActionGetFieldsByIssueTypeParamsSchema = schema.object({
id: schema.string(),
});
export const ExecutorSubActionGetIssuesParamsSchema = schema.object({ title: schema.string() });
export const ExecutorSubActionGetIssueParamsSchema = schema.object({ id: schema.string() });
export const ExecutorParamsSchema = schema.oneOf([
schema.object({
@ -82,4 +85,12 @@ export const ExecutorParamsSchema = schema.oneOf([
subAction: schema.literal('fieldsByIssueType'),
subActionParams: ExecutorSubActionGetFieldsByIssueTypeParamsSchema,
}),
schema.object({
subAction: schema.literal('issues'),
subActionParams: ExecutorSubActionGetIssuesParamsSchema,
}),
schema.object({
subAction: schema.literal('issue'),
subActionParams: ExecutorSubActionGetIssueParamsSchema,
}),
]);

View file

@ -95,6 +95,14 @@ const fieldsResponse = {
},
};
const issueResponse = {
id: '10267',
key: 'RJ-107',
fields: { summary: 'Test title' },
};
const issuesResponse = [issueResponse];
describe('Jira service', () => {
let service: ExternalService;
@ -219,6 +227,7 @@ describe('Jira service', () => {
labels: [],
issueType: '10006',
priority: 'High',
parent: null,
},
});
@ -264,6 +273,7 @@ describe('Jira service', () => {
labels: [],
priority: 'High',
issueType: null,
parent: null,
},
});
@ -308,6 +318,7 @@ describe('Jira service', () => {
labels: [],
issueType: '10006',
priority: 'High',
parent: 'RJ-107',
},
});
@ -324,6 +335,7 @@ describe('Jira service', () => {
issuetype: { id: '10006' },
labels: [],
priority: { name: 'High' },
parent: { key: 'RJ-107' },
},
},
});
@ -344,6 +356,7 @@ describe('Jira service', () => {
labels: [],
issueType: '10006',
priority: 'High',
parent: null,
},
})
).rejects.toThrow(
@ -370,6 +383,7 @@ describe('Jira service', () => {
labels: [],
issueType: '10006',
priority: 'High',
parent: null,
},
});
@ -398,6 +412,7 @@ describe('Jira service', () => {
labels: [],
issueType: '10006',
priority: 'High',
parent: 'RJ-107',
},
});
@ -414,6 +429,7 @@ describe('Jira service', () => {
priority: { name: 'High' },
issuetype: { id: '10006' },
project: { key: 'CK' },
parent: { key: 'RJ-107' },
},
},
});
@ -435,6 +451,7 @@ describe('Jira service', () => {
labels: [],
issueType: '10006',
priority: 'High',
parent: null,
},
})
).rejects.toThrow(
@ -916,4 +933,96 @@ describe('Jira service', () => {
});
});
});
describe('getIssues', () => {
test('it should return the issues', async () => {
requestMock.mockImplementation(() => ({
data: {
issues: issuesResponse,
},
}));
const res = await service.getIssues('Test title');
expect(res).toEqual([
{
id: '10267',
key: 'RJ-107',
title: 'Test title',
},
]);
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => ({
data: {
issues: issuesResponse,
},
}));
await service.getIssues('Test title');
expect(requestMock).toHaveBeenLastCalledWith({
axios,
logger,
method: 'get',
url: `https://siem-kibana.atlassian.net/rest/api/2/search?jql=project=CK and summary ~"Test title"`,
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
const error: ResponseError = new Error('An error has occurred');
error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } };
throw error;
});
expect(service.getIssues('Test title')).rejects.toThrow(
'[Action][Jira]: Unable to get issues. Error: An error has occurred. Reason: Could not get issue types'
);
});
});
describe('getIssue', () => {
test('it should return a single issue', async () => {
requestMock.mockImplementation(() => ({
data: issueResponse,
}));
const res = await service.getIssue('RJ-107');
expect(res).toEqual({
id: '10267',
key: 'RJ-107',
title: 'Test title',
});
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => ({
data: {
issues: issuesResponse,
},
}));
await service.getIssue('RJ-107');
expect(requestMock).toHaveBeenLastCalledWith({
axios,
logger,
method: 'get',
url: `https://siem-kibana.atlassian.net/rest/api/2/issue/RJ-107`,
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
const error: ResponseError = new Error('An error has occurred');
error.response = { data: { errors: { issuetypes: 'Could not get issue types' } } };
throw error;
});
expect(service.getIssue('RJ-107')).rejects.toThrow(
'[Action][Jira]: Unable to get issue with id RJ-107. Error: An error has occurred. Reason: Could not get issue types'
);
});
});
});

View file

@ -53,6 +53,8 @@ export const createExternalService = (
const getIssueTypeFieldsOldAPIURL = `${url}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&issuetypeIds={issueTypeId}&expand=projects.issuetypes.fields`;
const getIssueTypesUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes`;
const getIssueTypeFieldsUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes/{issueTypeId}`;
const searchUrl = `${url}/${BASE_URL}/search`;
const axiosInstance = axios.create({
auth: { username: email, password: apiToken },
});
@ -90,6 +92,10 @@ export const createExternalService = (
fields = { ...fields, priority: { name: incident.priority } };
}
if (incident.parent) {
fields = { ...fields, parent: { key: incident.parent } };
}
return fields;
};
@ -119,6 +125,17 @@ export const createExternalService = (
};
}, {});
const normalizeSearchResults = (
issues: Array<{ id: string; key: string; fields: { summary: string } }>
) =>
issues.map((issue) => ({ id: issue.id, key: issue.key, title: issue.fields?.summary ?? null }));
const normalizeIssue = (issue: { id: string; key: string; fields: { summary: string } }) => ({
id: issue.id,
key: issue.key,
title: issue.fields?.summary ?? null,
});
const getIncident = async (id: string) => {
try {
const res = await request({
@ -378,6 +395,54 @@ export const createExternalService = (
}
};
const getIssues = async (title: string) => {
const query = `${searchUrl}?jql=project=${projectKey} and summary ~"${title}"`;
try {
const res = await request({
axios: axiosInstance,
method: 'get',
url: query,
logger,
proxySettings,
});
return normalizeSearchResults(res.data?.issues ?? []);
} catch (error) {
throw new Error(
getErrorMessage(
i18n.NAME,
`Unable to get issues. Error: ${error.message}. Reason: ${createErrorMessage(
error.response?.data?.errors ?? {}
)}`
)
);
}
};
const getIssue = async (id: string) => {
const getIssueUrl = `${incidentUrl}/${id}`;
try {
const res = await request({
axios: axiosInstance,
method: 'get',
url: getIssueUrl,
logger,
proxySettings,
});
return normalizeIssue(res.data ?? {});
} catch (error) {
throw new Error(
getErrorMessage(
i18n.NAME,
`Unable to get issue with id ${id}. Error: ${error.message}. Reason: ${createErrorMessage(
error.response?.data?.errors ?? {}
)}`
)
);
}
};
return {
getIncident,
createIncident,
@ -386,5 +451,7 @@ export const createExternalService = (
getCapabilities,
getIssueTypes,
getFieldsByIssueType,
getIssues,
getIssue,
};
};

View file

@ -17,6 +17,8 @@ import {
ExecutorSubActionGetCapabilitiesParamsSchema,
ExecutorSubActionGetIssueTypesParamsSchema,
ExecutorSubActionGetFieldsByIssueTypeParamsSchema,
ExecutorSubActionGetIssuesParamsSchema,
ExecutorSubActionGetIssueParamsSchema,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { IncidentConfigurationSchema } from '../case/schema';
@ -60,7 +62,7 @@ export type ExternalServiceParams = Record<string, unknown>;
export type Incident = Pick<
ExecutorSubActionPushParams,
'description' | 'priority' | 'labels' | 'issueType'
'description' | 'priority' | 'labels' | 'issueType' | 'parent'
> & { summary: string };
export interface CreateIncidentParams {
@ -83,6 +85,13 @@ export type GetFieldsByIssueTypeResponse = Record<
{ allowedValues: Array<{}>; defaultValue: {} }
>;
export type GetIssuesResponse = Array<{ id: string; key: string; title: string }>;
export interface GetIssueResponse {
id: string;
key: string;
title: string;
}
export interface ExternalService {
getIncident: (id: string) => Promise<ExternalServiceParams | undefined>;
createIncident: (params: CreateIncidentParams) => Promise<ExternalServiceIncidentResponse>;
@ -91,6 +100,8 @@ export interface ExternalService {
getCapabilities: () => Promise<ExternalServiceParams>;
getIssueTypes: () => Promise<GetIssueTypesResponse>;
getFieldsByIssueType: (issueTypeId: string) => Promise<GetFieldsByIssueTypeResponse>;
getIssues: (title: string) => Promise<GetIssuesResponse>;
getIssue: (id: string) => Promise<GetIssueResponse>;
}
export interface PushToServiceApiParams extends ExecutorSubActionPushParams {
@ -117,6 +128,12 @@ export type ExecutorSubActionGetFieldsByIssueTypeParams = TypeOf<
typeof ExecutorSubActionGetFieldsByIssueTypeParamsSchema
>;
export type ExecutorSubActionGetIssuesParams = TypeOf<
typeof ExecutorSubActionGetIssuesParamsSchema
>;
export type ExecutorSubActionGetIssueParams = TypeOf<typeof ExecutorSubActionGetIssueParamsSchema>;
export interface ExternalServiceApiHandlerArgs {
externalService: ExternalService;
mapping: Map<string, any> | null;
@ -149,6 +166,16 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
comments?: ExternalServiceCommentResponse[];
}
export interface GetIssuesHandlerArgs {
externalService: ExternalService;
params: ExecutorSubActionGetIssuesParams;
}
export interface GetIssueHandlerArgs {
externalService: ExternalService;
params: ExecutorSubActionGetIssueParams;
}
export interface ExternalServiceApi {
handshake: (args: HandshakeApiHandlerArgs) => Promise<void>;
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>;
@ -157,12 +184,16 @@ export interface ExternalServiceApi {
fieldsByIssueType: (
args: GetFieldsByIssueTypeHandlerArgs
) => Promise<GetFieldsByIssueTypeResponse>;
issues: (args: GetIssuesHandlerArgs) => Promise<GetIssuesResponse>;
issue: (args: GetIssueHandlerArgs) => Promise<GetIssueResponse>;
}
export type JiraExecutorResultData =
| PushToServiceResponse
| GetIssueTypesResponse
| GetFieldsByIssueTypeResponse;
| GetFieldsByIssueTypeResponse
| GetIssuesResponse
| GetIssueResponse;
export interface Fields {
[key: string]: string | string[] | { name: string } | { key: string } | { id: string };

View file

@ -42,3 +42,41 @@ export async function getFieldsByIssueType({
signal,
});
}
export async function getIssues({
http,
signal,
connectorId,
title,
}: {
http: HttpSetup;
signal: AbortSignal;
connectorId: string;
title: string;
}): Promise<Record<string, any>> {
return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, {
body: JSON.stringify({
params: { subAction: 'issues', subActionParams: { title } },
}),
signal,
});
}
export async function getIssue({
http,
signal,
connectorId,
id,
}: {
http: HttpSetup;
signal: AbortSignal;
connectorId: string;
id: string;
}): Promise<Record<string, any>> {
return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, {
body: JSON.stringify({
params: { subAction: 'getIncident', subActionParams: { id } },
}),
signal,
});
}

View file

@ -31,8 +31,10 @@ const actionParams = {
priority: 'High',
savedObjectId: '123',
externalId: null,
parent: null,
},
};
const connector = {
secrets: {},
config: {},
@ -237,5 +239,6 @@ describe('JiraParamsFields renders', () => {
expect(wrapper.find('[data-test-subj="prioritySelect"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="labelsComboBox"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="search-parent-issues"]').exists()).toBeFalsy();
});
});

View file

@ -19,6 +19,7 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var
import { JiraActionParams } from './types';
import { useGetIssueTypes } from './use_get_issue_types';
import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type';
import { SearchIssues } from './search_issues';
const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionParams>> = ({
actionParams,
@ -30,7 +31,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
http,
toastNotifications,
}) => {
const { title, description, comments, issueType, priority, labels, savedObjectId } =
const { title, description, comments, issueType, priority, labels, parent, savedObjectId } =
actionParams.subActionParams || {};
const [issueTypesSelectOptions, setIssueTypesSelectOptions] = useState<EuiSelectOption[]>([]);
@ -62,6 +63,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
const hasPriority = useMemo(() => Object.prototype.hasOwnProperty.call(fields, 'priority'), [
fields,
]);
const hasParent = useMemo(() => Object.prototype.hasOwnProperty.call(fields, 'parent'), [fields]);
useEffect(() => {
const options = issueTypes.map((type) => ({
@ -179,6 +181,34 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
/>
</EuiFormRow>
<EuiHorizontalRule />
{hasParent && (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.parentIssueSearchLabel',
{
defaultMessage: 'Parent issue',
}
)}
>
<SearchIssues
selectedValue={parent}
http={http}
toastNotifications={toastNotifications}
actionConnector={actionConnector}
onChange={(parentIssueKey) => {
editSubActionProperty('parent', parentIssueKey);
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</>
)}
<>
{hasPriority && (
<>

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo, useEffect, useCallback, useState, memo } from 'react';
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../../types';
import { useGetIssues } from './use_get_issues';
import { useGetSingleIssue } from './use_get_single_issue';
import * as i18n from './translations';
interface Props {
selectedValue: string | null;
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
actionConnector?: ActionConnector;
onChange: (parentIssueKey: string) => void;
}
const SearchIssuesComponent: React.FC<Props> = ({
selectedValue,
http,
toastNotifications,
actionConnector,
onChange,
}) => {
const [query, setQuery] = useState<string | null>(null);
const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>(
[]
);
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
const { isLoading: isLoadingIssues, issues } = useGetIssues({
http,
toastNotifications,
actionConnector,
query,
});
const { isLoading: isLoadingSingleIssue, issue: singleIssue } = useGetSingleIssue({
http,
toastNotifications,
actionConnector,
id: selectedValue,
});
useEffect(() => setOptions(issues.map((issue) => ({ label: issue.title, value: issue.key }))), [
issues,
]);
useEffect(() => {
if (isLoadingSingleIssue || singleIssue == null) {
return;
}
const singleIssueAsOptions = [{ label: singleIssue.title, value: singleIssue.key }];
setOptions(singleIssueAsOptions);
setSelectedOptions(singleIssueAsOptions);
}, [singleIssue, isLoadingSingleIssue]);
const onSearchChange = useCallback((searchVal: string) => {
setQuery(searchVal);
}, []);
const onChangeComboBox = useCallback(
(changedOptions) => {
setSelectedOptions(changedOptions);
onChange(changedOptions[0].value);
},
[onChange]
);
const inputPlaceholder = useMemo(
(): string =>
isLoadingIssues || isLoadingSingleIssue
? i18n.SEARCH_ISSUES_LOADING
: i18n.SEARCH_ISSUES_PLACEHOLDER,
[isLoadingIssues, isLoadingSingleIssue]
);
return (
<EuiComboBox
singleSelection
fullWidth
placeholder={inputPlaceholder}
data-test-sub={'search-parent-issues'}
aria-label={i18n.SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL}
options={options}
isLoading={isLoadingIssues || isLoadingSingleIssue}
onSearchChange={onSearchChange}
selectedOptions={selectedOptions}
onChange={onChangeComboBox}
/>
);
};
export const SearchIssues = memo(SearchIssuesComponent);

View file

@ -131,3 +131,40 @@ export const FIELDS_API_ERROR = i18n.translate(
defaultMessage: 'Unable to get fields',
}
);
export const ISSUES_API_ERROR = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetIssuesMessage',
{
defaultMessage: 'Unable to get issues',
}
);
export const GET_ISSUE_API_ERROR = (id: string) =>
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.unableToGetIssueMessage',
{
defaultMessage: 'Unable to get issue with id {id}',
values: { id },
}
);
export const SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesComboBoxAriaLabel',
{
defaultMessage: 'Select parent issue',
}
);
export const SEARCH_ISSUES_PLACEHOLDER = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesComboBoxPlaceholder',
{
defaultMessage: 'Select parent issue',
}
);
export const SEARCH_ISSUES_LOADING = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.jira.searchIssuesLoading',
{
defaultMessage: 'Loading...',
}
);

View file

@ -22,6 +22,7 @@ export interface JiraActionParams {
issueType: string;
priority: string;
labels: string[];
parent: string | null;
};
}

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isEmpty, debounce } from 'lodash/fp';
import { useState, useEffect, useRef } from 'react';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../../types';
import { getIssues } from './api';
import * as i18n from './translations';
type Issues = Array<{ id: string; key: string; title: string }>;
interface Props {
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
actionConnector?: ActionConnector;
query: string | null;
}
export interface UseGetIssues {
issues: Issues;
isLoading: boolean;
}
export const useGetIssues = ({
http,
actionConnector,
toastNotifications,
query,
}: Props): UseGetIssues => {
const [isLoading, setIsLoading] = useState(false);
const [issues, setIssues] = useState<Issues>([]);
const abortCtrl = useRef(new AbortController());
useEffect(() => {
let didCancel = false;
const fetchData = debounce(500, async () => {
if (!actionConnector || isEmpty(query)) {
setIsLoading(false);
return;
}
abortCtrl.current = new AbortController();
setIsLoading(true);
try {
const res = await getIssues({
http,
signal: abortCtrl.current.signal,
connectorId: actionConnector.id,
title: query ?? '',
});
if (!didCancel) {
setIsLoading(false);
setIssues(res.data ?? []);
if (res.status && res.status === 'error') {
toastNotifications.addDanger({
title: i18n.ISSUES_API_ERROR,
text: `${res.serviceMessage ?? res.message}`,
});
}
}
} catch (error) {
if (!didCancel) {
toastNotifications.addDanger({
title: i18n.ISSUES_API_ERROR,
text: error.message,
});
}
}
});
abortCtrl.current.abort();
fetchData();
return () => {
didCancel = true;
setIsLoading(false);
abortCtrl.current.abort();
};
}, [http, actionConnector, toastNotifications, query]);
return {
issues,
isLoading,
};
};

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useState, useEffect, useRef } from 'react';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../../types';
import { getIssue } from './api';
import * as i18n from './translations';
interface Issue {
id: string;
key: string;
title: string;
}
interface Props {
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
id: string | null;
actionConnector?: ActionConnector;
}
export interface UseGetSingleIssue {
issue: Issue | null;
isLoading: boolean;
}
export const useGetSingleIssue = ({
http,
toastNotifications,
actionConnector,
id,
}: Props): UseGetSingleIssue => {
const [isLoading, setIsLoading] = useState(false);
const [issue, setIssue] = useState<Issue | null>(null);
const abortCtrl = useRef(new AbortController());
useEffect(() => {
let didCancel = false;
const fetchData = async () => {
if (!actionConnector || !id) {
setIsLoading(false);
return;
}
abortCtrl.current = new AbortController();
setIsLoading(true);
try {
const res = await getIssue({
http,
signal: abortCtrl.current.signal,
connectorId: actionConnector.id,
id,
});
if (!didCancel) {
setIsLoading(false);
setIssue(res.data ?? {});
if (res.status && res.status === 'error') {
toastNotifications.addDanger({
title: i18n.GET_ISSUE_API_ERROR(id),
text: `${res.serviceMessage ?? res.message}`,
});
}
}
} catch (error) {
if (!didCancel) {
toastNotifications.addDanger({
title: i18n.GET_ISSUE_API_ERROR(id),
text: error.message,
});
}
}
};
abortCtrl.current.abort();
fetchData();
return () => {
didCancel = true;
setIsLoading(false);
abortCtrl.current.abort();
};
}, [http, actionConnector, id, toastNotifications]);
return {
isLoading,
issue,
};
};

View file

@ -333,7 +333,7 @@ export default function jiraTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subAction]: expected value to equal [pushToService]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subAction]: expected value to equal [pushToService]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]',
});
});
});
@ -351,7 +351,7 @@ export default function jiraTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]',
});
});
});
@ -369,7 +369,7 @@ export default function jiraTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]',
});
});
});
@ -392,7 +392,7 @@ export default function jiraTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]',
});
});
});
@ -420,7 +420,7 @@ export default function jiraTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]',
});
});
});
@ -448,7 +448,7 @@ export default function jiraTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [3.subAction]: expected value to equal [issueTypes]\n- [4.subAction]: expected value to equal [fieldsByIssueType]\n- [5.subAction]: expected value to equal [issues]\n- [6.subAction]: expected value to equal [issue]',
});
});
});