diff --git a/server/client.go b/server/client.go index 6dad89342..14c04ad25 100644 --- a/server/client.go +++ b/server/client.go @@ -57,6 +57,7 @@ type ProjectService interface { GetProject(key string) (*jira.Project, error) ListProjects(query string, limit int, expandIssueTypes bool) (jira.ProjectList, error) GetIssueTypes(projectID string) ([]jira.IssueType, error) + ListProjectStatuses(projectID string) ([]*IssueTypeWithStatuses, error) } // SearchService is the interface for search-related APIs. diff --git a/server/client_cloud.go b/server/client_cloud.go index 4e734be0d..08bd1e64f 100644 --- a/server/client_cloud.go +++ b/server/client_cloud.go @@ -5,6 +5,7 @@ package main import ( "encoding/json" + "fmt" "strconv" jira "github.com/andygrunwald/go-jira" @@ -16,6 +17,11 @@ type jiraCloudClient struct { JiraClient } +type IssueTypeWithStatuses struct { + *jira.IssueType + Statuses []*jira.Status `json:"statuses"` +} + func newCloudClient(jiraClient *jira.Client) Client { return &jiraCloudClient{ JiraClient: JiraClient{ @@ -133,3 +139,12 @@ func (client jiraCloudClient) GetIssueTypes(projectID string) ([]jira.IssueType, return result, nil } + +func (client jiraCloudClient) ListProjectStatuses(projectID string) ([]*IssueTypeWithStatuses, error) { + var result []*IssueTypeWithStatuses + if err := client.RESTGet(fmt.Sprintf("3/project/%s/statuses", projectID), nil, &result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/server/client_server.go b/server/client_server.go index 28c5f6737..b05174ebd 100644 --- a/server/client_server.go +++ b/server/client_server.go @@ -208,3 +208,12 @@ func (client jiraServerClient) GetIssueTypes(projectID string) ([]jira.IssueType return result.IssueTypes, nil } + +func (client jiraServerClient) ListProjectStatuses(projectID string) ([]*IssueTypeWithStatuses, error) { + var result []*IssueTypeWithStatuses + if err := client.RESTGet(fmt.Sprintf("2/project/%s/statuses", projectID), nil, &result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/server/issue.go b/server/issue.go index d56da1ff7..8a0fa8d70 100644 --- a/server/issue.go +++ b/server/issue.go @@ -29,8 +29,16 @@ const ( descriptionField = "description" resolutionField = "resolution" securityLevelField = "security" + + QueryParamInstanceID = "instance_id" + QueryParamProjectID = "project_id" ) +type CreateMetaInfo struct { + *jira.CreateMetaInfo + IssueTypesWithStatuses []*IssueTypeWithStatuses `json:"issue_types_with_statuses"` +} + func makePost(userID, channelID, message string) *model.Post { return &model.Post{ UserId: userID, @@ -372,16 +380,29 @@ func (p *Plugin) httpGetCreateIssueMetadataForProjects(w http.ResponseWriter, r return respondJSON(w, cimd) } -func (p *Plugin) GetCreateIssueMetadataForProjects(instanceID, mattermostUserID types.ID, projectKeys string) (*jira.CreateMetaInfo, error) { +func (p *Plugin) GetCreateIssueMetadataForProjects(instanceID, mattermostUserID types.ID, projectKeys string) (*CreateMetaInfo, error) { client, _, _, err := p.getClient(instanceID, mattermostUserID) if err != nil { return nil, err } - return client.GetCreateMetaInfo(p.API, &jira.GetQueryOptions{ + projectStatuses, err := client.ListProjectStatuses(projectKeys) + if err != nil { + return nil, err + } + + metaInfo, err := client.GetCreateMetaInfo(p.API, &jira.GetQueryOptions{ Expand: "projects.issuetypes.fields", ProjectKeys: projectKeys, }) + if err != nil { + return nil, err + } + + return &CreateMetaInfo{ + metaInfo, + projectStatuses, + }, nil } func (p *Plugin) httpGetSearchIssues(w http.ResponseWriter, r *http.Request) (int, error) { diff --git a/server/webhook.go b/server/webhook.go index a5353f79a..c87676bea 100644 --- a/server/webhook.go +++ b/server/webhook.go @@ -20,6 +20,7 @@ const ( commentDeleted = "comment_deleted" commentUpdated = "comment_updated" commentCreated = "comment_created" + issueCreated = "jira:issue_created" worklogUpdated = "jira:worklog_updated" ) diff --git a/server/webhook_jira.go b/server/webhook_jira.go index 417414cd3..ca5d1a6b3 100644 --- a/server/webhook_jira.go +++ b/server/webhook_jira.go @@ -42,8 +42,9 @@ func (jwh *JiraWebhook) expandIssue(p *Plugin, instanceID types.ID) error { return nil } + // TODO: The data sent for "Status" field is invalid in case of issue created event, so we are fetching it again here. This can be updated when the issue is fixed from Jira side. // Jira Cloud comment event. We need to fetch issue data because it is not expanded in webhook payload. - isCommentEvent := jwh.WebhookEvent == commentCreated || jwh.WebhookEvent == commentUpdated || jwh.WebhookEvent == commentDeleted + isCommentEvent := jwh.WebhookEvent == commentCreated || jwh.WebhookEvent == commentUpdated || jwh.WebhookEvent == commentDeleted || jwh.WebhookEvent == issueCreated if isCommentEvent { if _, ok := instance.(*cloudInstance); ok { issue, err := p.getIssueDataForCloudWebhook(instance, jwh.Issue.ID) @@ -53,7 +54,12 @@ func (jwh *JiraWebhook) expandIssue(p *Plugin, instanceID types.ID) error { jwh.Issue = *issue } else if instance, ok := instance.(*cloudOAuthInstance); ok { - mmUserID, err := p.userStore.LoadMattermostUserID(instanceID, jwh.Comment.Author.AccountID) + accountID := jwh.Comment.Author.AccountID + if jwh.WebhookEvent == issueCreated { + accountID = jwh.Issue.Fields.Creator.AccountID + } + + mmUserID, err := p.userStore.LoadMattermostUserID(instanceID, accountID) if err != nil { // User is not connected, so we try to fall back to JWT bot if instance.JWTInstance == nil { diff --git a/webapp/src/components/data_selectors/jira_epic_selector/__snapshots__/jira_epic_selector.test.tsx.snap b/webapp/src/components/data_selectors/jira_epic_selector/__snapshots__/jira_epic_selector.test.tsx.snap index a3fbc6c96..f6b8cd984 100644 --- a/webapp/src/components/data_selectors/jira_epic_selector/__snapshots__/jira_epic_selector.test.tsx.snap +++ b/webapp/src/components/data_selectors/jira_epic_selector/__snapshots__/jira_epic_selector.test.tsx.snap @@ -9,6 +9,78 @@ exports[`components/JiraEpicSelector should match snapshot 1`] = ` issueMetadata={ Object { "expand": "projects", + "issue_types_with_statuses": Array [ + Object { + "id": "10002", + "name": "Task", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + ], + }, + Object { + "id": "10003", + "name": "Sub-task", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10001", + "name": "Story", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10004", + "name": "Bug", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10000", + "name": "Epic", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + Object { + "id": "1003", + "name": "Bug", + }, + ], + }, + ], "projects": Array [ Object { "expand": "issuetypes", diff --git a/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filter.test.tsx.snap b/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filter.test.tsx.snap index 8cd36dee5..57171bb58 100644 --- a/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filter.test.tsx.snap +++ b/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filter.test.tsx.snap @@ -86,6 +86,10 @@ exports[`components/ChannelSubscriptionFilter should match snapshot 1`] = ` "label": "Priority", "value": "priority", }, + Object { + "label": "Status", + "value": "status", + }, ] } removeValidate={[MockFunction]} diff --git a/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filters.test.tsx.snap b/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filters.test.tsx.snap index a33535f58..7521c39ef 100644 --- a/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filters.test.tsx.snap +++ b/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filters.test.tsx.snap @@ -96,11 +96,42 @@ exports[`components/ChannelSubscriptionFilters should match snapshot 1`] = ` }, ], }, + Object { + "issueTypes": Array [ + Object { + "id": "10001", + "name": "Bug", + }, + ], + "key": "status", + "name": "Status", + "schema": Object { + "type": "array", + }, + "values": Array [ + Object { + "label": "TODO", + "value": "1001", + }, + ], + }, ] } instanceID="https://something.atlassian.net" issueMetadata={ Object { + "issue_types_with_statuses": Array [ + Object { + "id": "10001", + "name": "Bug", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + ], + }, + ], "projects": Array [ Object { "issuetypes": Array [ @@ -214,10 +245,41 @@ exports[`components/ChannelSubscriptionFilters should match snapshot 1`] = ` }, ], }, + Object { + "issueTypes": Array [ + Object { + "id": "10001", + "name": "Bug", + }, + ], + "key": "status", + "name": "Status", + "schema": Object { + "type": "array", + }, + "values": Array [ + Object { + "label": "TODO", + "value": "1001", + }, + ], + }, ] } issueMetadata={ Object { + "issue_types_with_statuses": Array [ + Object { + "id": "10001", + "name": "Bug", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + ], + }, + ], "projects": Array [ Object { "issuetypes": Array [ diff --git a/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap b/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap index 41f46544e..9afb32970 100644 --- a/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap +++ b/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap @@ -837,12 +837,123 @@ exports[`components/EditChannelSubscription should match snapshot after fetching }, ], }, + Object { + "issueTypes": Array [ + Object { + "id": "10002", + "name": "Task", + }, + Object { + "id": "10003", + "name": "Sub-task", + }, + Object { + "id": "10001", + "name": "Story", + }, + Object { + "id": "10004", + "name": "Bug", + }, + Object { + "id": "10000", + "name": "Epic", + }, + ], + "key": "status", + "name": "Status", + "schema": Object { + "type": "array", + }, + "values": Array [ + Object { + "label": "TODO", + "value": "1001", + }, + Object { + "label": "In Progress", + "value": "1002", + }, + ], + }, ] } instanceID="https://something.atlassian.net" issueMetadata={ Object { "expand": "projects", + "issue_types_with_statuses": Array [ + Object { + "id": "10002", + "name": "Task", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + ], + }, + Object { + "id": "10003", + "name": "Sub-task", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10001", + "name": "Story", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10004", + "name": "Bug", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10000", + "name": "Epic", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + Object { + "id": "1003", + "name": "Bug", + }, + ], + }, + ], "projects": Array [ Object { "expand": "issuetypes", diff --git a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.test.tsx b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.test.tsx index 5cd103ae9..f53534e0b 100644 --- a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.test.tsx +++ b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.test.tsx @@ -14,7 +14,7 @@ import {getCustomFieldFiltersForProjects, isEpicLinkField} from 'utils/jira_issu import ChannelSubscriptionFilter, {Props} from './channel_subscription_filter'; describe('components/ChannelSubscriptionFilter', () => { - const fields = getCustomFieldFiltersForProjects(issueMetadata, [issueMetadata.projects[0].key]); + const fields = getCustomFieldFiltersForProjects(issueMetadata, [issueMetadata.projects[0].key], []); const baseProps: Props = { theme: {}, fields, diff --git a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.tsx b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.tsx index 25eddc79b..eeb8c3a1c 100644 --- a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.tsx +++ b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.tsx @@ -5,7 +5,7 @@ import {Theme} from 'mattermost-redux/types/preferences'; import ReactSelectSetting from 'components/react_select_setting'; import JiraEpicSelector from 'components/data_selectors/jira_epic_selector'; -import {isEpicLinkField, isMultiSelectField, isLabelField, isSecurityLevelField} from 'utils/jira_issue_metadata'; +import {isEpicLinkField, isMultiSelectField, isLabelField, isSecurityLevelField, FIELD_KEY_STATUS} from 'utils/jira_issue_metadata'; import {FilterField, FilterValue, ReactSelectOption, IssueMetadata, IssueType, FilterFieldInclusion} from 'types/model'; import ConfirmModal from 'components/confirm_modal'; import JiraAutoCompleteSelector from 'components/data_selectors/jira_autocomplete_selector'; @@ -199,6 +199,13 @@ export default class ChannelSubscriptionFilter extends React.PureComponent opt.value === FilterFieldInclusion.INCLUDE_ALL); inclusionSelectOptions.splice(includeAllIndex, 1); diff --git a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.test.tsx b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.test.tsx index f3f1b5df1..1b0582807 100644 --- a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.test.tsx +++ b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.test.tsx @@ -46,7 +46,7 @@ describe('components/ChannelSubscriptionFilters', () => { const baseProps: Props = { theme: {}, - fields: getCustomFieldFiltersForProjects(issueMetadata, [issueMetadata.projects[0].key]), + fields: getCustomFieldFiltersForProjects(issueMetadata, [issueMetadata.projects[0].key], []), values: [{ key: 'priority', inclusion: FilterFieldInclusion.INCLUDE_ANY, diff --git a/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx b/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx index f189396f9..57a11b29d 100644 --- a/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx +++ b/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx @@ -187,7 +187,7 @@ export default class EditChannelSubscription extends PureComponent let conflictingFields = null; if (finalValue.length > this.state.filters.issue_types.length) { - const filterFields = getCustomFieldFiltersForProjects(this.state.jiraIssueMetadata, this.state.filters.projects); + const filterFields = getCustomFieldFiltersForProjects(this.state.jiraIssueMetadata, this.state.filters.projects, this.state.filters.issue_types); conflictingFields = getConflictingFields( filterFields, finalValue, @@ -227,7 +227,7 @@ export default class EditChannelSubscription extends PureComponent state.getMetaDataErr = `The project ${projectKeys[0]} is unavailable. Please contact your system administrator.`; } - const filterFields = getCustomFieldFiltersForProjects(jiraIssueMetadata, this.state.filters.projects); + const filterFields = getCustomFieldFiltersForProjects(jiraIssueMetadata, this.state.filters.projects, this.state.filters.issue_types); for (const v of this.state.filters.fields) { if (!filterFields.find((f) => f.key === v.key)) { state.error = 'A field in this subscription has been removed from Jira, so the subscription is invalid. When this form is submitted, the configured field will be removed from the subscription to make the subscription valid again.'; @@ -296,7 +296,7 @@ export default class EditChannelSubscription extends PureComponent return; } - const filterFields = getCustomFieldFiltersForProjects(this.state.jiraIssueMetadata, this.state.filters.projects); + const filterFields = getCustomFieldFiltersForProjects(this.state.jiraIssueMetadata, this.state.filters.projects, this.state.filters.issue_types); const configuredFields = this.state.filters.fields.concat([]); for (const v of this.state.filters.fields) { if (!filterFields.find((f) => f.key === v.key)) { @@ -345,7 +345,7 @@ export default class EditChannelSubscription extends PureComponent const issueOptions = issueTypes.map((it) => ({label: it.name, value: it.id})); const customFields = getCustomFieldValuesForEvents(this.state.jiraIssueMetadata, this.state.filters.projects); - const filterFields = getCustomFieldFiltersForProjects(this.state.jiraIssueMetadata, this.state.filters.projects); + const filterFields = getCustomFieldFiltersForProjects(this.state.jiraIssueMetadata, this.state.filters.projects, this.state.filters.issue_types); const eventOptions = JiraEventOptions.concat(customFields); diff --git a/webapp/src/components/modals/create_issue/__snapshots__/create_issue_form.test.tsx.snap b/webapp/src/components/modals/create_issue/__snapshots__/create_issue_form.test.tsx.snap index cc12bb8f4..717916135 100644 --- a/webapp/src/components/modals/create_issue/__snapshots__/create_issue_form.test.tsx.snap +++ b/webapp/src/components/modals/create_issue/__snapshots__/create_issue_form.test.tsx.snap @@ -781,6 +781,78 @@ exports[`components/CreateIssue should match snapshot with error 1`] = ` issueMetadata={ Object { "expand": "projects", + "issue_types_with_statuses": Array [ + Object { + "id": "10002", + "name": "Task", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + ], + }, + Object { + "id": "10003", + "name": "Sub-task", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10001", + "name": "Story", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10004", + "name": "Bug", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10000", + "name": "Epic", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + Object { + "id": "1003", + "name": "Bug", + }, + ], + }, + ], "projects": Array [ Object { "expand": "issuetypes", @@ -4239,6 +4311,78 @@ exports[`components/CreateIssue should match snapshot with form filled 1`] = ` issueMetadata={ Object { "expand": "projects", + "issue_types_with_statuses": Array [ + Object { + "id": "10002", + "name": "Task", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + ], + }, + Object { + "id": "10003", + "name": "Sub-task", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10001", + "name": "Story", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10004", + "name": "Bug", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + ], + }, + Object { + "id": "10000", + "name": "Epic", + "statuses": Array [ + Object { + "id": "1001", + "name": "TODO", + }, + Object { + "id": "1002", + "name": "In Progress", + }, + Object { + "id": "1003", + "name": "Bug", + }, + ], + }, + ], "projects": Array [ Object { "expand": "issuetypes", diff --git a/webapp/src/testdata/cloud-get-create-issue-metadata-for-project-many-fields.json b/webapp/src/testdata/cloud-get-create-issue-metadata-for-project-many-fields.json index 05a31103e..287f950ee 100644 --- a/webapp/src/testdata/cloud-get-create-issue-metadata-for-project-many-fields.json +++ b/webapp/src/testdata/cloud-get-create-issue-metadata-for-project-many-fields.json @@ -1,12 +1,85 @@ { "expand": "projects", + "issue_types_with_statuses": [ + { + "id": "10002", + "name": "Task", + "statuses": [ + { + "id": "1001", + "name": "TODO" + } + ] + }, + { + "id": "10003", + "name": "Sub-task", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10001", + "name": "Story", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10004", + "name": "Bug", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10000", + "name": "Epic", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + }, + { + "id": "1003", + "name": "Bug" + } + ] + } + ], "projects": [{ "expand": "issuetypes", "self": "https://mmtest.atlassian.net/rest/api/2/project/10008", "id": "10008", "key": "KT", "name": "Koch Testing", - "issuetypes": [{ + "issuetypes": [ + { "self": "https://mmtest.atlassian.net/rest/api/2/issuetype/10002", "id": "10002", "description": "A task that needs to be done.", diff --git a/webapp/src/testdata/cloud-get-create-issue-metadata-for-project.json b/webapp/src/testdata/cloud-get-create-issue-metadata-for-project.json index 101281157..15d44f1d4 100644 --- a/webapp/src/testdata/cloud-get-create-issue-metadata-for-project.json +++ b/webapp/src/testdata/cloud-get-create-issue-metadata-for-project.json @@ -1,5 +1,77 @@ { "expand": "projects", + "issue_types_with_statuses": [ + { + "id": "10002", + "name": "Task", + "statuses": [ + { + "id": "1001", + "name": "TODO" + } + ] + }, + { + "id": "10003", + "name": "Sub-task", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10001", + "name": "Story", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10004", + "name": "Bug", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10000", + "name": "Epic", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + }, + { + "id": "1003", + "name": "Bug" + } + ] + } + ], "projects": [ { "expand": "issuetypes", diff --git a/webapp/src/testdata/jira-issue-metadata-helpers.ts b/webapp/src/testdata/jira-issue-metadata-helpers.ts index 3a54b6096..45428f6d9 100644 --- a/webapp/src/testdata/jira-issue-metadata-helpers.ts +++ b/webapp/src/testdata/jira-issue-metadata-helpers.ts @@ -15,5 +15,17 @@ export const useFieldForIssueMetadata = (field: JiraField, key: string): IssueMe }], }, ], + issue_types_with_statuses: [ + { + id: '10001', + name: 'Bug', + statuses: [ + { + id: '1001', + name: 'TODO', + }, + ], + }, + ], }; }; diff --git a/webapp/src/testdata/server-get-create-issue-metadata-for-project-many-fields.json b/webapp/src/testdata/server-get-create-issue-metadata-for-project-many-fields.json index 7b96249e4..83b34d0d4 100644 --- a/webapp/src/testdata/server-get-create-issue-metadata-for-project-many-fields.json +++ b/webapp/src/testdata/server-get-create-issue-metadata-for-project-many-fields.json @@ -1,5 +1,77 @@ { "expand": "projects", + "issue_types_with_statuses": [ + { + "id": "10002", + "name": "Task", + "statuses": [ + { + "id": "1001", + "name": "TODO" + } + ] + }, + { + "id": "10003", + "name": "Sub-task", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10001", + "name": "Story", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10004", + "name": "Bug", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10000", + "name": "Epic", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + }, + { + "id": "1003", + "name": "Bug" + } + ] + } + ], "projects": [{ "expand": "issuetypes", "self": "http://localhost:8080/rest/api/2/project/10401", diff --git a/webapp/src/testdata/server-get-create-issue-metadata-for-project.json b/webapp/src/testdata/server-get-create-issue-metadata-for-project.json index d2e032aff..ce27e356b 100644 --- a/webapp/src/testdata/server-get-create-issue-metadata-for-project.json +++ b/webapp/src/testdata/server-get-create-issue-metadata-for-project.json @@ -1,5 +1,77 @@ { "expand": "projects", + "issue_types_with_statuses": [ + { + "id": "10002", + "name": "Task", + "statuses": [ + { + "id": "1001", + "name": "TODO" + } + ] + }, + { + "id": "10003", + "name": "Sub-task", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10001", + "name": "Story", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10004", + "name": "Bug", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + } + ] + }, + { + "id": "10000", + "name": "Epic", + "statuses": [ + { + "id": "1001", + "name": "TODO" + }, + { + "id": "1002", + "name": "In Progress" + }, + { + "id": "1003", + "name": "Bug" + } + ] + } + ], "projects": [{ "expand": "issuetypes", "self": "http://localhost:8080/rest/api/2/project/10000", diff --git a/webapp/src/types/model.ts b/webapp/src/types/model.ts index 9f8b4bb8d..2d5ac5efb 100644 --- a/webapp/src/types/model.ts +++ b/webapp/src/types/model.ts @@ -65,8 +65,20 @@ export type Project = { issuetypes: IssueType[]; } +export type IssueTypeWithStatuses = { + id: string; + name: string; + statuses: Status[]; +} + export type IssueMetadata = { projects: Project[]; + issue_types_with_statuses: IssueTypeWithStatuses[]; +} + +export type Status = { + id: string; + name: string; } export type ProjectMetadata = { diff --git a/webapp/src/utils/jira_issue_metadata.test.tsx b/webapp/src/utils/jira_issue_metadata.test.tsx index f3c401b53..258addb4e 100644 --- a/webapp/src/utils/jira_issue_metadata.test.tsx +++ b/webapp/src/utils/jira_issue_metadata.test.tsx @@ -5,10 +5,10 @@ import createMeta from 'testdata/cloud-get-create-issue-metadata-for-project-man import {ticketData} from 'testdata/get-ticket-metadata-for-tooltip'; import {useFieldForIssueMetadata} from 'testdata/jira-issue-metadata-helpers'; -import {IssueMetadata, JiraField, FilterField, ChannelSubscriptionFilters, FilterFieldInclusion, IssueType, Project} from 'types/model'; +import {IssueMetadata, JiraField, FilterField, ChannelSubscriptionFilters, FilterFieldInclusion} from 'types/model'; import {IssueAction, TicketDetails} from 'types/tooltip'; -import {getCustomFieldFiltersForProjects, generateJQLStringFromSubscriptionFilters, getConflictingFields, getJiraTicketDetails} from './jira_issue_metadata'; +import {getCustomFieldFiltersForProjects, generateJQLStringFromSubscriptionFilters, getConflictingFields, getJiraTicketDetails, getStatusField} from './jira_issue_metadata'; describe('utils/jira_issue_metadata', () => { const useField = (field: JiraField, key: string): IssueMetadata => { @@ -32,12 +32,12 @@ describe('utils/jira_issue_metadata', () => { test('should return a list of fields', () => { const projectKey = createMeta.projects[0].key; - const actual = getCustomFieldFiltersForProjects(createMeta, [projectKey]); + const actual = getCustomFieldFiltersForProjects(createMeta, [projectKey], []); expect(actual).not.toBe(null); expect(actual.length).toBeGreaterThan(0); }); - test('should return blank list if there are no available values', () => { + test('should return only the status field if there are no available values', () => { const field = { hasDefaultValue: false, key: 'customfield_10021', @@ -57,9 +57,10 @@ describe('utils/jira_issue_metadata', () => { const metadata = useFieldForIssueMetadata(field, 'customfield_10021'); const projectKey = metadata.projects[0].key; - const actual = getCustomFieldFiltersForProjects(metadata, [projectKey]); + const actual = getCustomFieldFiltersForProjects(metadata, [projectKey], []); expect(actual).not.toBe(null); - expect(actual.length).toBe(0); + expect(actual.length).toBe(1); + expect(actual[0].name).toBe('Status'); }); test('should return options for multi-select options', () => { @@ -92,13 +93,14 @@ describe('utils/jira_issue_metadata', () => { const metadata = useFieldForIssueMetadata(field, 'custom1'); const projectKey = metadata.projects[0].key; - const actual = getCustomFieldFiltersForProjects(metadata, [projectKey]); + const actual = getCustomFieldFiltersForProjects(metadata, [projectKey], []); expect(actual).not.toBe(null); - expect(actual.length).toBe(1); + expect(actual.length).toBe(2); expect(actual[0].key).toEqual('custom1'); expect(actual[0].name).toEqual('MJK - Checkbox'); expect(actual[0].values).toEqual([{value: '10033', label: '1'}, {value: '10034', label: '2'}]); + expect(actual[1].name).toBe('Status'); }); test('should return options for single-select options', () => { @@ -132,13 +134,14 @@ describe('utils/jira_issue_metadata', () => { const metadata = useFieldForIssueMetadata(field, 'custom1'); const projectKey = metadata.projects[0].key; - const actual = getCustomFieldFiltersForProjects(metadata, [projectKey]); + const actual = getCustomFieldFiltersForProjects(metadata, [projectKey], []); expect(actual).not.toBe(null); - expect(actual.length).toBe(1); + expect(actual.length).toBe(2); expect(actual[0].key).toEqual('custom1'); expect(actual[0].name).toEqual('MJK - Radio Buttons'); expect(actual[0].values).toEqual([{value: '10035', label: '1'}, {value: '10036', label: '2'}]); + expect(actual[1].name).toBe('Status'); }); test('should return options for priority', () => { @@ -197,13 +200,14 @@ describe('utils/jira_issue_metadata', () => { const metadata = useFieldForIssueMetadata(field, 'priority'); const projectKey = metadata.projects[0].key; - const actual = getCustomFieldFiltersForProjects(metadata, [projectKey]); + const actual = getCustomFieldFiltersForProjects(metadata, [projectKey], []); expect(actual).not.toBe(null); - expect(actual.length).toBe(1); + expect(actual.length).toBe(2); expect(actual[0].key).toEqual('priority'); expect(actual[0].name).toEqual('Priority'); expect(actual[0].values).toEqual([{value: '1', label: 'Highest'}, {value: '2', label: 'High'}, {value: '3', label: 'Medium'}, {value: '4', label: 'Low'}, {value: '5', label: 'Lowest'}]); + expect(actual[1].name).toBe('Status'); }); test('should return options for fix version', () => { @@ -231,13 +235,14 @@ describe('utils/jira_issue_metadata', () => { const metadata = useFieldForIssueMetadata(field, 'fixVersions'); const projectKey = metadata.projects[0].key; - const actual = getCustomFieldFiltersForProjects(metadata, [projectKey]); + const actual = getCustomFieldFiltersForProjects(metadata, [projectKey], []); expect(actual).not.toBe(null); - expect(actual.length).toBe(1); + expect(actual.length).toBe(2); expect(actual[0].key).toEqual('fixVersions'); expect(actual[0].name).toEqual('Fix versions'); expect(actual[0].values).toEqual([{value: '10000', label: '5.14 (August 2019)'}]); + expect(actual[1].name).toBe('Status'); }); test('should return options for security level', () => { @@ -284,13 +289,51 @@ describe('utils/jira_issue_metadata', () => { const metadata = useFieldForIssueMetadata(field, 'security'); const projectKey = metadata.projects[0].key; - const actual = getCustomFieldFiltersForProjects(metadata, [projectKey]); + const actual = getCustomFieldFiltersForProjects(metadata, [projectKey], []); expect(actual).not.toBe(null); - expect(actual.length).toBe(1); + expect(actual.length).toBe(2); expect(actual[0].key).toEqual('security'); expect(actual[0].name).toEqual('Security Level'); expect(actual[0].values).toEqual([{value: '10001', label: 'Admin only'}, {value: '10000', label: 'Everyone'}, {value: '10002', label: 'Staff'}]); + expect(actual[1].name).toBe('Status'); + }); + + test('getStatusField should return options for statuses for selected issue types only', () => { + const actual = getStatusField(createMeta, ['10001']); + expect(actual).not.toBe(null); + + if (actual) { + expect(actual.key).toEqual('status'); + expect(actual.name).toEqual('Status'); + expect(actual.values).toEqual([{value: '1001', label: 'TODO'}, {value: '1002', label: 'In Progress'}]); + } + }); + + test('getStatusField should return options for statuses for all issue types if no issue type is selected', () => { + const actual = getStatusField(createMeta, []); + expect(actual).not.toBe(null); + + if (actual) { + expect(actual.key).toEqual('status'); + expect(actual.name).toEqual('Status'); + expect(actual.values).toEqual([{value: '1001', label: 'TODO'}, {value: '1002', label: 'In Progress'}, {value: '1003', label: 'Bug'}]); + } + }); + + test('getStatusField should return null for statuses if statuses information is empty', () => { + const metadata: IssueMetadata = { + projects: [ + { + key: 'TEST', + issuetypes: [], + }, + ], + issue_types_with_statuses: [], + }; + + const actual = getStatusField(metadata, []); + expect(actual).toBe(null); }); test('should return options with a `userDefined` flag for array of strings', () => { @@ -316,13 +359,14 @@ describe('utils/jira_issue_metadata', () => { const metadata = useFieldForIssueMetadata(field, 'custom1'); const projectKey = metadata.projects[0].key; - const actual = getCustomFieldFiltersForProjects(metadata, [projectKey]); + const actual = getCustomFieldFiltersForProjects(metadata, [projectKey], []); expect(actual).not.toBe(null); - expect(actual.length).toBe(1); + expect(actual.length).toBe(2); expect(actual[0].key).toEqual('custom1'); expect(actual[0].name).toEqual('MJK - Labels'); expect(actual[0].userDefined).toEqual(true); + expect(actual[1].name).toBe('Status'); }); test('getConflictingFields should return a list of fields with conflicts', () => { @@ -388,6 +432,18 @@ describe('utils/jira_issue_metadata', () => { }; const issueMetadata: IssueMetadata = { + issue_types_with_statuses: [ + { + id: '10001', + name: 'Bug', + statuses: [], + }, + { + id: '10002', + name: 'Task', + statuses: [], + }, + ], projects: [{ key: 'KT', issuetypes: [ diff --git a/webapp/src/utils/jira_issue_metadata.tsx b/webapp/src/utils/jira_issue_metadata.tsx index 26f1140aa..ad55ce8ae 100644 --- a/webapp/src/utils/jira_issue_metadata.tsx +++ b/webapp/src/utils/jira_issue_metadata.tsx @@ -16,6 +16,8 @@ import { FilterFieldInclusion, JiraFieldCustomTypeEnums, JiraFieldTypeEnums, + Status, + IssueTypeWithStatuses, } from 'types/model'; import {IssueAction, TicketData, TicketDetails} from 'types/tooltip'; @@ -26,6 +28,8 @@ type FieldWithInfo = JiraField & { issueTypeMeta: IssueTypeIdentifier; } +export const FIELD_KEY_STATUS = 'status'; + // This is a replacement for the Array.flat() function which will be polyfilled by Babel // in our 5.16 release. Remove this and replace with .flat() then. const flatten = (arr: any[]) => { @@ -186,7 +190,48 @@ function isValidFieldForFilter(field: JiraField): boolean { (type === 'array' && allowedArrayTypes.includes(items)); } -export function getCustomFieldFiltersForProjects(metadata: IssueMetadata | null, projectKeys: string[]): FilterField[] { +export function getStatusField(metadata: IssueMetadata | null, selectedIssueTypes: string[]): FilterField | null { + // Filtering out the statuses on the basis of selected issue types + const issueTypesWithStatuses = metadata && metadata.issue_types_with_statuses; + const keys = new Set(); + const statuses: Status[] = []; + if (issueTypesWithStatuses) { + for (const issueType of issueTypesWithStatuses) { + if (selectedIssueTypes.includes(issueType.id) || !selectedIssueTypes.length) { + for (const status of issueType.statuses) { + if (!keys.has(status.id)) { + keys.add(status.id); + statuses.push(status); + } + } + } + } + } + + if (!statuses.length) { + return null; + } + + return { + key: FIELD_KEY_STATUS, + name: 'Status', + schema: { + type: 'array', + }, + values: statuses.map((value) => ({ + label: value.name, + value: value.id, + })), + issueTypes: metadata && metadata.issue_types_with_statuses.map((type) => { + return { + id: type.id, + name: type.name, + }; + }), + } as FilterField; +} + +export function getCustomFieldFiltersForProjects(metadata: IssueMetadata | null, projectKeys: string[], issueTypes: string[]): FilterField[] { const fields = getCustomFieldsForProjects(metadata, projectKeys).filter(isValidFieldForFilter); const selectFields = fields.filter((field) => Boolean(field.allowedValues && field.allowedValues.length)) as (SelectField & FieldWithInfo)[]; const populatedFields = selectFields.map((field) => { @@ -225,6 +270,11 @@ export function getCustomFieldFiltersForProjects(metadata: IssueMetadata | null, } as FilterField); } + const statusField = getStatusField(metadata, issueTypes); + if (statusField) { + result.push(statusField); + } + return sortByName(result); }