diff --git a/app/assets/images/logos/zentao.svg b/app/assets/images/logos/zentao.svg new file mode 100644 index 0000000000000000000000000000000000000000..f2937c16454a2ccdb647f1089660ddcc44c3c56f --- /dev/null +++ b/app/assets/images/logos/zentao.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/app/assets/javascripts/integrations/external_issue/issues_list/components/external_issues_list_empty_state.vue b/app/assets/javascripts/integrations/external_issue/issues_list/components/external_issues_list_empty_state.vue new file mode 100644 index 0000000000000000000000000000000000000000..264326fc081634224832adacac6e31922f3b5e93 --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_list/components/external_issues_list_empty_state.vue @@ -0,0 +1,70 @@ + + + diff --git a/app/assets/javascripts/integrations/external_issue/issues_list/components/external_issues_list_root.vue b/app/assets/javascripts/integrations/external_issue/issues_list/components/external_issues_list_root.vue new file mode 100644 index 0000000000000000000000000000000000000000..acb2a6c78336462361faa3f5d7f58f24a7e7973d --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_list/components/external_issues_list_root.vue @@ -0,0 +1,284 @@ + + + diff --git a/app/assets/javascripts/integrations/external_issue/issues_list/constants.js b/app/assets/javascripts/integrations/external_issue/issues_list/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..b8d2532e108b112f5b18541ba8d7ade1bf6e7f95 --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_list/constants.js @@ -0,0 +1,3 @@ +import { __ } from '~/locale'; + +export const ISSUES_LIST_FETCH_ERROR = __('An error occurred while loading issues'); diff --git a/app/assets/javascripts/integrations/external_issue/issues_list/external_issue_list_bundle.js b/app/assets/javascripts/integrations/external_issue/issues_list/external_issue_list_bundle.js new file mode 100644 index 0000000000000000000000000000000000000000..f752b9cbc50516be8ca958f69a7e2fc2947c61f6 --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_list/external_issue_list_bundle.js @@ -0,0 +1,66 @@ +import Vue from 'vue'; + +import { IssuableStates } from '~/issuable_list/constants'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { queryToObject } from '~/lib/utils/url_utility'; + +import externalIssuesListApp from './components/external_issues_list_root.vue'; +import getApolloProvider from './graphql'; + +/** + * + * @param {provides} provides necessary attributes for issues_list + * make sure + * { + * 'getIssuesQuery', + * 'externalIssuesLogo', + * 'externalIssueName', + * 'searchInputPlaceholderText', + * 'recentSearchesStorageKey', + * 'createNewIssueText', + * 'logoContainerClass' + * } + * is provided + */ +export default function externalIssuesListFactory({ provides, query }) { + return function initExternalIssuesList({ mountPointSelector }) { + const mountPointEl = document.querySelector(mountPointSelector); + + if (!mountPointEl) { + return null; + } + + const { + page = 1, + initialState = IssuableStates.Opened, + initialSortBy = 'created_desc', + } = mountPointEl.dataset; + + const initialFilterParams = Object.assign( + convertObjectPropsToCamelCase( + queryToObject(window.location.search.substring(1), { gatherArrays: true }), + { + dropKeys: ['scope', 'utf8', 'state', 'sort'], // These keys are unsupported/unnecessary + }, + ), + ); + + return new Vue({ + el: mountPointEl, + provide: { + ...mountPointEl.dataset, + page: parseInt(page, 10), + initialState, + initialSortBy, + ...provides, + }, + apolloProvider: getApolloProvider(query), + render: (createElement) => + createElement(externalIssuesListApp, { + props: { + initialFilterParams, + }, + }), + }); + }; +} diff --git a/app/assets/javascripts/integrations/external_issue/issues_list/graphql/index.js b/app/assets/javascripts/integrations/external_issue/issues_list/graphql/index.js new file mode 100644 index 0000000000000000000000000000000000000000..996f62db1c0eccd90cc32ab81ddbf5c41e433354 --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_list/graphql/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export default (externalIssues) => { + const resolvers = { + Query: { + externalIssues, + }, + }; + + const defaultClient = createDefaultClient(resolvers, { assumeImmutableResults: true }); + + return new VueApollo({ + defaultClient, + }); +}; diff --git a/app/assets/javascripts/integrations/external_issue/issues_show/api.js b/app/assets/javascripts/integrations/external_issue/issues_show/api.js new file mode 100644 index 0000000000000000000000000000000000000000..95fe28c90e230689af73714287453ee9c85c537d --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_show/api.js @@ -0,0 +1,37 @@ +import axios from '~/lib/utils/axios_utils'; + +export const fetchIssue = async (issuePath) => { + return axios.get(issuePath).then(({ data }) => { + return data; + }); +}; + +export const fetchIssueStatuses = () => { + // We are using mock data here which should come from the backend + return new Promise((resolve) => { + setTimeout(() => { + // eslint-disable-next-line @gitlab/require-i18n-strings + resolve([{ title: 'In Progress' }, { title: 'Done' }]); + }, 1000); + }); +}; + +export const updateIssue = (issue, { labels = [], status = undefined }) => { + // We are using mock call here which should become a backend call + return new Promise((resolve) => { + setTimeout(() => { + const addedLabels = labels.filter((label) => label.set); + const removedLabelsIds = labels.filter((label) => !label.set).map((label) => label.id); + + const finalLabels = [...issue.labels, ...addedLabels].filter( + (label) => !removedLabelsIds.includes(label.id), + ); + + resolve({ + ...issue, + ...(status ? { status } : {}), + labels: finalLabels, + }); + }, 1000); + }); +}; diff --git a/app/assets/javascripts/integrations/external_issue/issues_show/components/external_issues_show_root.vue b/app/assets/javascripts/integrations/external_issue/issues_show/components/external_issues_show_root.vue new file mode 100644 index 0000000000000000000000000000000000000000..77d736fcc2fb2456c577833067300909412c7cd8 --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_show/components/external_issues_show_root.vue @@ -0,0 +1,219 @@ + + + diff --git a/app/assets/javascripts/integrations/external_issue/issues_show/components/note.vue b/app/assets/javascripts/integrations/external_issue/issues_show/components/note.vue new file mode 100644 index 0000000000000000000000000000000000000000..ff7fbd75ad4b056d74be47fdd8ec313bafba817e --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_show/components/note.vue @@ -0,0 +1,100 @@ + + + diff --git a/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/assignee.vue b/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/assignee.vue new file mode 100644 index 0000000000000000000000000000000000000000..81048183987d109dc91c1da5f18a7b11cae67fde --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/assignee.vue @@ -0,0 +1,86 @@ + + + diff --git a/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/external_issues_sidebar_root.vue b/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/external_issues_sidebar_root.vue new file mode 100644 index 0000000000000000000000000000000000000000..f255bc7468a82f9835bf95ffe8e1ce65978ba735 --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/external_issues_sidebar_root.vue @@ -0,0 +1,170 @@ + + + diff --git a/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/issue_due_date.vue b/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/issue_due_date.vue new file mode 100644 index 0000000000000000000000000000000000000000..66360feda1abfc4b0f9772d2eca2cf24fa7a95ae --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/issue_due_date.vue @@ -0,0 +1,76 @@ + + + diff --git a/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/issue_field.vue b/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/issue_field.vue new file mode 100644 index 0000000000000000000000000000000000000000..2e24d77632f9a407501d12326cc5ec6887f8e303 --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/issue_field.vue @@ -0,0 +1,141 @@ + + + diff --git a/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/issue_field_dropdown.vue b/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/issue_field_dropdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..37df42c80e5a52c8484ed23b8cff77737d83ed04 --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_show/components/sidebar/issue_field_dropdown.vue @@ -0,0 +1,66 @@ + + + diff --git a/app/assets/javascripts/integrations/external_issue/issues_show/constants.js b/app/assets/javascripts/integrations/external_issue/issues_show/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..29bb2dedfd2554649972bc02c93e426b19f7c994 --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_show/constants.js @@ -0,0 +1,13 @@ +import { __ } from '~/locale'; + +export const issueStates = { + OPENED: 'opened', + CLOSED: 'closed', +}; + +export const issueStateLabels = { + [issueStates.OPENED]: __('Open'), + [issueStates.CLOSED]: __('Closed'), +}; + +export const labelsFilterParam = 'labels'; diff --git a/app/assets/javascripts/integrations/external_issue/issues_show/external_issues_show_bundle.js b/app/assets/javascripts/integrations/external_issue/issues_show/external_issues_show_bundle.js new file mode 100644 index 0000000000000000000000000000000000000000..d00307ebeb052cb36bbfb627440c1f8dc5e7fe29 --- /dev/null +++ b/app/assets/javascripts/integrations/external_issue/issues_show/external_issues_show_bundle.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; + +import ExternalIssueShowRoot from './components/external_issues_show_root.vue'; + +/** + * + * @param @required {Object} provides + * @type provides: { + * failFetchingIssueText: string + * failFetchingIssueStatusText: string + * failUpdatingIssueLabelsText: string + * failUpdatingIssueStatusText: string + * featureFlagCanEditLabelKey: string + * featureFlagCanEditStatusKey: string + * seeMoreDetailText: string + * statusDropdownEmptyText: string + * titleText: string + * userTypeText: string + * userTypeTooltipText: string + * } + */ + +export default function ExternalIssueShowFactory(provides) { + return function initZentaoIssueShow({ mountPointSelector }) { + const mountPointEl = document.querySelector(mountPointSelector); + + if (!mountPointEl) { + return null; + } + + const { issuesShowPath, issuesListPath } = mountPointEl.dataset; + + return new Vue({ + el: mountPointEl, + provide: { + issuesShowPath, + issuesListPath, + ...provides, + }, + render: (createElement) => createElement(ExternalIssueShowRoot), + }); + }; +} diff --git a/app/assets/javascripts/integrations/zentao/issues_list/constants/index.js b/app/assets/javascripts/integrations/zentao/issues_list/constants/index.js new file mode 100644 index 0000000000000000000000000000000000000000..b8d2532e108b112f5b18541ba8d7ade1bf6e7f95 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_list/constants/index.js @@ -0,0 +1,3 @@ +import { __ } from '~/locale'; + +export const ISSUES_LIST_FETCH_ERROR = __('An error occurred while loading issues'); diff --git a/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_label.fragment.graphql b/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_label.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..3efbcc1f2b67d1ef7d187321412c321b09a209bf --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_label.fragment.graphql @@ -0,0 +1,6 @@ +fragment ZentaoLabel on Label { + title + name + color + textColor +} diff --git a/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_user.fragment.graphql b/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_user.fragment.graphql new file mode 100644 index 0000000000000000000000000000000000000000..873e5b15c564f5209730249d74154d053be539c7 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_list/graphql/fragments/zentao_user.fragment.graphql @@ -0,0 +1,5 @@ +fragment ZentaoUser on UserCore { + avatarUrl + name + webUrl +} diff --git a/app/assets/javascripts/integrations/zentao/issues_list/graphql/queries/get_zentao_issues.query.graphql b/app/assets/javascripts/integrations/zentao/issues_list/graphql/queries/get_zentao_issues.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..af9ff4a33c473064ee864c43bad0a37496bad2c6 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_list/graphql/queries/get_zentao_issues.query.graphql @@ -0,0 +1,46 @@ +#import "../fragments/zentao_label.fragment.graphql" +#import "../fragments/zentao_user.fragment.graphql" + +query externalIssues( + $issuesFetchPath: String + $search: String + $labels: String + $sort: String + $state: String + $page: Integer +) { + externalIssues( + issuesFetchPath: $issuesFetchPath + search: $search + labels: $labels + sort: $sort + state: $state + page: $page + ) @client { + errors + pageInfo { + total + page + } + nodes { + id + projectId + createdAt + updatedAt + closedAt + title + webUrl + gitlabWebUrl + status + labels { + ...ZentaoLabel + } + assignees { + ...ZentaoUser + } + author { + ...ZentaoUser + } + } + } +} diff --git a/app/assets/javascripts/integrations/zentao/issues_list/graphql/resolvers/zentao_issues.js b/app/assets/javascripts/integrations/zentao/issues_list/graphql/resolvers/zentao_issues.js new file mode 100644 index 0000000000000000000000000000000000000000..9ce868225e106912fe15119f7807cd3a8f3783b0 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_list/graphql/resolvers/zentao_issues.js @@ -0,0 +1,83 @@ +import { DEFAULT_PAGE_SIZE } from '~/issuable_list/constants'; +import axios from '~/lib/utils/axios_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { ISSUES_LIST_FETCH_ERROR } from '../../constants'; + +const transformZentaoIssueAssignees = (zentaoIssue) => { + return zentaoIssue.assignees.map((assignee) => ({ + __typename: 'UserCore', + ...assignee, + })); +}; + +const transformZentaoIssueAuthor = (zentaoIssue, authorId) => { + return { + __typename: 'UserCore', + ...zentaoIssue.author, + id: authorId, + }; +}; + +const transformZentaoIssueLabels = (zentaoIssue) => { + return zentaoIssue.labels.map((label) => ({ + __typename: 'Label', // eslint-disable-line @gitlab/require-i18n-strings + ...label, + })); +}; + +const transformZentaoIssuePageInfo = (responseHeaders = {}) => { + return { + __typename: 'ZentaoIssuesPageInfo', + page: parseInt(responseHeaders['x-page'], 10) ?? 1, + total: parseInt(responseHeaders['x-total'], 10) ?? 0, + }; +}; + +export const transformZentaoIssuesREST = (response) => { + const { headers, data: zentaoIssues } = response; + + return { + __typename: 'ZentaoIssues', + errors: [], + pageInfo: transformZentaoIssuePageInfo(headers), + nodes: zentaoIssues.map((rawIssue, index) => { + const zentaoIssue = convertObjectPropsToCamelCase(rawIssue, { deep: true }); + return { + __typename: 'ZentaoIssue', + ...zentaoIssue, + id: rawIssue.id, + author: transformZentaoIssueAuthor(zentaoIssue, index), + labels: transformZentaoIssueLabels(zentaoIssue), + assignees: transformZentaoIssueAssignees(zentaoIssue), + }; + }), + }; +}; + +export default function zentaoIssuesResolver( + _, + { issuesFetchPath, search, page, state, sort, labels }, +) { + return axios + .get(issuesFetchPath, { + params: { + limit: DEFAULT_PAGE_SIZE, + page, + state, + sort, + labels, + search, + }, + }) + .then((res) => { + return transformZentaoIssuesREST(res); + }) + .catch((error) => { + return { + __typename: 'ZentaoIssues', + errors: error?.response?.data?.errors || [ISSUES_LIST_FETCH_ERROR], + pageInfo: transformZentaoIssuePageInfo(), + nodes: [], + }; + }); +} diff --git a/app/assets/javascripts/integrations/zentao/issues_list/zentao_issues_list_bundle.js b/app/assets/javascripts/integrations/zentao/issues_list/zentao_issues_list_bundle.js new file mode 100644 index 0000000000000000000000000000000000000000..79d1480c84d4172aa3c6a524f1faccddef7ee6bf --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_list/zentao_issues_list_bundle.js @@ -0,0 +1,24 @@ +import zentaoLogo from 'images/logos/zentao.svg'; +import externalIssuesListFactory from '~/integrations/external_issue/issues_list/external_issue_list_bundle'; +import { s__ } from '~/locale'; +import getIssuesQuery from './graphql/queries/get_zentao_issues.query.graphql'; +import zentaoIssues from './graphql/resolvers/zentao_issues'; + +export default externalIssuesListFactory({ + query: zentaoIssues, + provides: { + getIssuesQuery, + externalIssuesLogo: zentaoLogo, + // This like below is passed to + // So we don't translate it since this should be a proper noun + // eslint-disable-next-line @gitlab/require-i18n-strings + externalIssueName: 'Zentao', + searchInputPlaceholderText: s__('Integrations|Search Zentao issues'), + recentSearchesStorageKey: 'zentao_issues', + createNewIssueText: s__('Integrations|Create new issue in Zentao'), + logoContainerClass: 'logo-container', + emptyStateNoIssueText: s__( + 'Integrations|Zentao issues display here when you create issues in your project in Zentao.', + ), + }, +}); diff --git a/app/assets/javascripts/integrations/zentao/issues_show/zentao_issues_show_bundle.js b/app/assets/javascripts/integrations/zentao/issues_show/zentao_issues_show_bundle.js new file mode 100644 index 0000000000000000000000000000000000000000..836f5c11c78701526e192b928cf1d7b4a4c16b0c --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_show/zentao_issues_show_bundle.js @@ -0,0 +1,26 @@ +import ExternalIssueShowFactory from '~/integrations/external_issue/issues_show/external_issues_show_bundle'; +import { __, s__ } from '~/locale'; + +export default ExternalIssueShowFactory({ + failFetchingIssueText: s__( + 'ZenTaoIntegration|Failed to load ZenTao issue. View the issue in ZenTao, or reload the page.', + ), + failFetchingIssueStatusText: s__( + 'ZenTaoIntegration|Failed to load ZenTao issue. View the issue in ZenTao, or reload the page.', + ), + failUpdatingIssueLabelsText: s__( + 'ZenTaoIntegration|Failed to update ZenTao issue labels. View the issue in ZenTao, or reload the page.', + ), + failUpdatingIssueStatusText: s__( + 'ZenTaoIntegration|Failed to update ZenTao issue status. View the issue in ZenTao, or reload the page.', + ), + featureFlagCanEditLabelKey: 'zentaoIssueDetailsEditLabels', + featureFlagCanEditStatusKey: 'zentaoIssueDetailsEditStatus', + seeMoreDetailText: s__( + `ZenTaoIntegration|Not all data may be displayed here. To view more details or make changes to this issue, go to %{linkStart}ZenTao%{linkEnd}.`, + ), + statusDropdownEmptyText: s__('ZenTaoIntegration|No available statuses'), + titleText: s__('ZenTaoIntegration|This issue is synchronized with ZenTao'), + userTypeText: __('ZenTao user'), + userTypeTooltipText: __('This is a ZenTao user.'), +}); diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue index 60b01a6d37f2eba578956e0c459346fe8c497f66..6dc7460b037421765cfe2104e59ae4e756c48df0 100644 --- a/app/assets/javascripts/issues_list/components/issuable.vue +++ b/app/assets/javascripts/issues_list/components/issuable.vue @@ -315,7 +315,7 @@ export default { {{ referencePath }} diff --git a/app/assets/javascripts/pages/projects/integrations/zentao/issues/index/index.js b/app/assets/javascripts/pages/projects/integrations/zentao/issues/index/index.js new file mode 100644 index 0000000000000000000000000000000000000000..90d70739460e0b126c23c5abffbbae2432f4eed3 --- /dev/null +++ b/app/assets/javascripts/pages/projects/integrations/zentao/issues/index/index.js @@ -0,0 +1,3 @@ +import initZentaoIssuesList from '~/integrations/zentao/issues_list/zentao_issues_list_bundle'; + +initZentaoIssuesList({ mountPointSelector: '.js-zentao-issues-list' }); diff --git a/app/assets/javascripts/pages/projects/integrations/zentao/issues/show/index.js b/app/assets/javascripts/pages/projects/integrations/zentao/issues/show/index.js new file mode 100644 index 0000000000000000000000000000000000000000..e922b7141c461d3dfefc434b83f41db400c59148 --- /dev/null +++ b/app/assets/javascripts/pages/projects/integrations/zentao/issues/show/index.js @@ -0,0 +1,4 @@ +import initZentaoIssueShow from '~/integrations/zentao/issues_show/zentao_issues_show_bundle'; +// import initJiraIssueShow from 'ee/integrations/jira/issues_show/jira_issues_show_bundle'; + +initZentaoIssueShow({ mountPointSelector: '.js-zentao-issues-show-app' }); diff --git a/app/assets/stylesheets/page_bundles/issues_list.scss b/app/assets/stylesheets/page_bundles/issues_list.scss index 8a958bdf0c5750cc1bbef47c88131ccf23f5c7fe..5af8902949a63f3937922170fc85c629583a74d1 100644 --- a/app/assets/stylesheets/page_bundles/issues_list.scss +++ b/app/assets/stylesheets/page_bundles/issues_list.scss @@ -43,3 +43,9 @@ opacity: 0.3; pointer-events: none; } + +.svg-container.logo-container { + svg { + vertical-align: text-bottom; + } +} diff --git a/app/views/projects/integrations/zentao/issues/index.html.haml b/app/views/projects/integrations/zentao/issues/index.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..c58ff6297ce52fff8bebca54f5e8e437c6158341 --- /dev/null +++ b/app/views/projects/integrations/zentao/issues/index.html.haml @@ -0,0 +1,10 @@ +- page_title _('Zentao issues') +- add_page_specific_style 'page_bundles/issues_list' + +.js-zentao-issues-list{ data: { issues_fetch_path: project_integrations_zentao_issues_path(@project, format: :json), +page: params[:page], +initial_state: params[:state], +initial_sort_by: params[:sort], +project_full_path: @project.full_path, +issue_create_url: @project.zentao_integration&.url, +empty_state_path: image_path('illustrations/issues.svg') } } diff --git a/app/views/projects/integrations/zentao/issues/show.html.haml b/app/views/projects/integrations/zentao/issues/show.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..c60a6a86f6b74cac8f075bae396c7cacdf39fd16 --- /dev/null +++ b/app/views/projects/integrations/zentao/issues/show.html.haml @@ -0,0 +1,5 @@ +- add_to_breadcrumbs _('Zentao issues'), project_integrations_zentao_issues_path(@project) +- breadcrumb_title zentao_issue_breadcrumb_link(@issue_json) +- page_title @issue_json[:title] + +.js-zentao-issues-show-app{ data: zentao_issues_show_data } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a43c54298db3cd92b1755dbed2ff131aaf1b2d9f..6105ec6f6742111984b507e89068b5f425fa6420 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -18058,6 +18058,9 @@ msgstr "" msgid "Integrations|Create new issue in Jira" msgstr "" +msgid "Integrations|Create new issue in Zentao" +msgstr "" + msgid "Integrations|Default settings are inherited from the group level." msgstr "" @@ -18151,6 +18154,9 @@ msgstr "" msgid "Integrations|Search Jira issues" msgstr "" +msgid "Integrations|Search Zentao issues" +msgstr "" + msgid "Integrations|Send notifications about project events to Unify Circuit." msgstr "" @@ -18193,6 +18199,9 @@ msgstr "" msgid "Integrations|You've activated every integration ๐ŸŽ‰" msgstr "" +msgid "Integrations|Zentao issues display here when you create issues in your project in Zentao." +msgstr "" + msgid "Interactive mode" msgstr "" @@ -34346,6 +34355,9 @@ msgstr "" msgid "This is a Jira user." msgstr "" +msgid "This is a ZenTao user." +msgstr "" + msgid "This is a confidential %{noteableTypeText}." msgstr "" @@ -39084,6 +39096,30 @@ msgstr[1] "" msgid "Your username is %{username}." msgstr "" +msgid "ZenTao user" +msgstr "" + +msgid "ZenTaoIntegration|Failed to load ZenTao issue. View the issue in ZenTao, or reload the page." +msgstr "" + +msgid "ZenTaoIntegration|Failed to update ZenTao issue labels. View the issue in ZenTao, or reload the page." +msgstr "" + +msgid "ZenTaoIntegration|Failed to update ZenTao issue status. View the issue in ZenTao, or reload the page." +msgstr "" + +msgid "ZenTaoIntegration|No available statuses" +msgstr "" + +msgid "ZenTaoIntegration|Not all data may be displayed here. To view more details or make changes to this issue, go to %{linkStart}ZenTao%{linkEnd}." +msgstr "" + +msgid "ZenTaoIntegration|This issue is synchronized with ZenTao" +msgstr "" + +msgid "Zentao issues" +msgstr "" + msgid "ZentaoIntegration|Base URL of the Zentao instance." msgstr "" diff --git a/spec/frontend/integrations/zentao/issues_list/components/__snapshots__/zentao_issues_list_root_spec.js.snap b/spec/frontend/integrations/zentao/issues_list/components/__snapshots__/zentao_issues_list_root_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..a7188f06bf014970d5de5ef67c5af6f8640d9bce --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_list/components/__snapshots__/zentao_issues_list_root_spec.js.snap @@ -0,0 +1,157 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ZentaoIssuesListRoot when request succeeds renders issuable-list component with correct props 1`] = ` +Object { + "currentPage": 1, + "currentTab": "opened", + "defaultPageSize": 2, + "enableLabelPermalinks": true, + "hasNextPage": false, + "hasPreviousPage": false, + "initialFilterValue": Array [], + "initialSortBy": "created_desc", + "isManualOrdering": false, + "issuableSymbol": "#", + "issuables": Array [ + Object { + "assignees": Array [ + Object { + "avatarUrl": null, + "name": "Kushal Pandya", + "webUrl": "https://gitlab-zentao.atlassian.net/people/1920938475", + }, + ], + "author": Object { + "avatarUrl": null, + "name": "jhope", + "webUrl": "https://gitlab-zentao.atlassian.net/people/5e32f803e127810e82875bc1", + }, + "closedAt": null, + "createdAt": "2020-03-19T14:31:51.281Z", + "gitlabWebUrl": "", + "id": 1, + "labels": Array [ + Object { + "color": "#0052CC", + "name": "backend", + "textColor": "#FFFFFF", + "title": "backend", + }, + ], + "projectId": 1, + "status": "Selected for Development", + "title": "Eius fuga voluptates.", + "updatedAt": "2020-10-20T07:01:45.865Z", + "webUrl": "https://gitlab-zentao.atlassian.net/browse/IG-31596", + }, + Object { + "assignees": Array [], + "author": Object { + "avatarUrl": null, + "name": "Gabe Weaver", + "webUrl": "https://gitlab-zentao.atlassian.net/people/5e320a31fe03e20c9d1dccde", + }, + "closedAt": null, + "createdAt": "2020-03-19T14:31:50.677Z", + "gitlabWebUrl": "", + "id": 2, + "labels": Array [], + "projectId": 1, + "status": "Backlog", + "title": "Hic sit sint ducimus ea et sint.", + "updatedAt": "2020-03-19T14:31:50.677Z", + "webUrl": "https://gitlab-zentao.atlassian.net/browse/IG-31595", + }, + Object { + "assignees": Array [], + "author": Object { + "avatarUrl": null, + "name": "Gabe Weaver", + "webUrl": "https://gitlab-zentao.atlassian.net/people/5e320a31fe03e20c9d1dccde", + }, + "closedAt": null, + "createdAt": "2020-03-19T14:31:50.012Z", + "gitlabWebUrl": "", + "id": 3, + "labels": Array [], + "projectId": 1, + "status": "Backlog", + "title": "Alias ut modi est labore.", + "updatedAt": "2020-03-19T14:31:50.012Z", + "webUrl": "https://gitlab-zentao.atlassian.net/browse/IG-31594", + }, + ], + "issuablesLoading": false, + "labelFilterParam": "labels", + "namespace": "gitlab-org/gitlab-test", + "nextPage": 2, + "previousPage": 0, + "recentSearchesStorageKey": "zentao_issues", + "searchInputPlaceholder": "Search Zentao issues", + "searchTokens": Array [ + Object { + "defaultLabels": Array [], + "fetchLabels": [Function], + "icon": "labels", + "operators": Array [ + Object { + "description": "is", + "value": "=", + }, + ], + "symbol": "~", + "title": "Label", + "token": "LabelTokenMock", + "type": "labels", + "unique": false, + }, + ], + "showBulkEditSidebar": false, + "showPaginationControls": true, + "sortOptions": Array [ + Object { + "id": 1, + "sortDirection": Object { + "ascending": "created_asc", + "descending": "created_desc", + }, + "title": "Created date", + }, + Object { + "id": 2, + "sortDirection": Object { + "ascending": "updated_asc", + "descending": "updated_desc", + }, + "title": "Last updated", + }, + ], + "tabCounts": null, + "tabs": Array [ + Object { + "id": "state-opened", + "name": "opened", + "title": "Open", + "titleTooltip": "Filter by issues that are currently opened.", + }, + Object { + "id": "state-closed", + "name": "closed", + "title": "Closed", + "titleTooltip": "Filter by issues that are currently closed.", + }, + Object { + "id": "state-all", + "name": "all", + "title": "All", + "titleTooltip": "Show all issues.", + }, + ], + "totalItems": 3, + "urlParams": Object { + "labels[]": undefined, + "search": undefined, + }, + "useKeysetPagination": false, +} +`; diff --git a/spec/frontend/integrations/zentao/issues_list/components/zentao_issues_list_empty_state_spec.js b/spec/frontend/integrations/zentao/issues_list/components/zentao_issues_list_empty_state_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2ee7e10ae0550f22c26648355948b39f3439168b --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_list/components/zentao_issues_list_empty_state_spec.js @@ -0,0 +1,202 @@ +import { GlEmptyState, GlSprintf, GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import ZentaoIssuesListEmptyState from '~/integrations/external_issue/issues_list/components/external_issues_list_empty_state.vue'; +import { IssuableStates } from '~/issuable_list/constants'; + +import { mockProvide } from '../mock_data'; + +const createComponent = (props = {}) => + shallowMount(ZentaoIssuesListEmptyState, { + provide: mockProvide, + propsData: { + currentState: 'opened', + issuesCount: { + [IssuableStates.Opened]: 0, + [IssuableStates.Closed]: 0, + [IssuableStates.All]: 0, + }, + hasFiltersApplied: false, + ...props, + }, + stubs: { GlEmptyState }, + }); + +describe('ZentaoIssuesListEmptyState', () => { + const titleDefault = + 'Zentao issues display here when you create issues in your project in Zentao.'; + const titleWhenFilters = 'Sorry, your filter produced no results'; + const titleWhenIssues = 'There are no open issues'; + + const descriptionWhenFilters = 'To widen your search, change or remove filters above'; + const descriptionWhenNoIssues = 'To keep this project going, create a new issue.'; + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('hasIssues', () => { + it('returns false when total of opened and closed issues within `issuesCount` is 0', () => { + expect(wrapper.vm.hasIssues).toBe(false); + }); + + it('returns true when total of opened and closed issues within `issuesCount` is more than 0', async () => { + wrapper.setProps({ + issuesCount: { + [IssuableStates.Opened]: 1, + [IssuableStates.Closed]: 1, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.hasIssues).toBe(true); + }); + }); + + describe('emptyStateTitle', () => { + it(`returns string "${titleWhenFilters}" when hasFiltersApplied prop is true`, async () => { + wrapper.setProps({ + hasFiltersApplied: true, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.emptyStateTitle).toBe(titleWhenFilters); + }); + + it(`returns string "${titleWhenIssues}" when hasFiltersApplied prop is false and hasIssues is true`, async () => { + wrapper.setProps({ + hasFiltersApplied: false, + issuesCount: { + [IssuableStates.Opened]: 1, + [IssuableStates.Closed]: 1, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.emptyStateTitle).toBe(titleWhenIssues); + }); + + it('returns default title string when both hasFiltersApplied and hasIssues props are false', async () => { + wrapper.setProps({ + hasFiltersApplied: false, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.emptyStateTitle).toBe(titleDefault); + }); + }); + + describe('emptyStateDescription', () => { + it(`returns string "${descriptionWhenFilters}" when hasFiltersApplied prop is true`, async () => { + wrapper.setProps({ + hasFiltersApplied: true, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.emptyStateDescription).toBe(descriptionWhenFilters); + }); + + it(`returns string "${descriptionWhenNoIssues}" when both hasFiltersApplied and hasIssues props are false`, async () => { + wrapper.setProps({ + hasFiltersApplied: false, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.emptyStateDescription).toBe(descriptionWhenNoIssues); + }); + + it(`returns empty string when hasFiltersApplied is false and hasIssues is true`, async () => { + wrapper.setProps({ + hasFiltersApplied: false, + issuesCount: { + [IssuableStates.Opened]: 1, + [IssuableStates.Closed]: 1, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.emptyStateDescription).toBe(''); + }); + }); + }); + + describe('template', () => { + it('renders gl-empty-state component', () => { + expect(wrapper.find(GlEmptyState).exists()).toBe(true); + }); + + it('renders empty state title', async () => { + const emptyStateEl = wrapper.find(GlEmptyState); + + expect(emptyStateEl.props()).toMatchObject({ + svgPath: mockProvide.emptyStatePath, + title: 'Zentao issues display here when you create issues in your project in Zentao.', + }); + + wrapper.setProps({ + hasFiltersApplied: true, + }); + + await wrapper.vm.$nextTick(); + + expect(emptyStateEl.props('title')).toBe('Sorry, your filter produced no results'); + + wrapper.setProps({ + hasFiltersApplied: false, + issuesCount: { + [IssuableStates.Opened]: 1, + [IssuableStates.Closed]: 1, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(emptyStateEl.props('title')).toBe('There are no open issues'); + }); + + it('renders empty state description', () => { + const descriptionEl = wrapper.find(GlSprintf); + + expect(descriptionEl.exists()).toBe(true); + expect(descriptionEl.attributes('message')).toBe( + 'To keep this project going, create a new issue.', + ); + }); + + it('does not render empty state description when issues are present', async () => { + wrapper.setProps({ + issuesCount: { + [IssuableStates.Opened]: 1, + [IssuableStates.Closed]: 1, + }, + }); + + await wrapper.vm.$nextTick(); + + const descriptionEl = wrapper.find(GlSprintf); + + expect(descriptionEl.exists()).toBe(false); + }); + + it('renders "Create new issue in Zentao" button', () => { + const buttonEl = wrapper.find(GlButton); + + expect(buttonEl.exists()).toBe(true); + expect(buttonEl.attributes('href')).toBe(mockProvide.issueCreateUrl); + expect(buttonEl.text()).toBe('Create new issue in Zentao'); + }); + }); +}); diff --git a/spec/frontend/integrations/zentao/issues_list/components/zentao_issues_list_root_spec.js b/spec/frontend/integrations/zentao/issues_list/components/zentao_issues_list_root_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cc3e2053189ac3184bdc7200208db285a3d49edf --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_list/components/zentao_issues_list_root_spec.js @@ -0,0 +1,330 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import VueApollo from 'vue-apollo'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import createFlash from '~/flash'; +import ExternalIssuesListRoot from '~/integrations/external_issue/issues_list/components/external_issues_list_root.vue'; +import { ISSUES_LIST_FETCH_ERROR } from '~/integrations/zentao/issues_list/constants'; +import zentaoIssues from '~/integrations/zentao/issues_list/graphql/resolvers/zentao_issues'; + +import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; + +import { mockProvide, mockZentaoIssues } from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/issuable_list/constants', () => ({ + DEFAULT_PAGE_SIZE: 2, + IssuableStates: jest.requireActual('~/issuable_list/constants').IssuableStates, + IssuableListTabs: jest.requireActual('~/issuable_list/constants').IssuableListTabs, + AvailableSortOptions: jest.requireActual('~/issuable_list/constants').AvailableSortOptions, +})); +jest.mock( + '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue', + () => 'LabelTokenMock', +); + +const resolvedValue = { + headers: { + 'x-page': 1, + 'x-total': mockZentaoIssues.length, + }, + data: mockZentaoIssues, +}; + +const localVue = createLocalVue(); + +const resolvers = { + Query: { + externalIssues: zentaoIssues, + }, +}; + +function createMockApolloProvider(mockResolvers = resolvers) { + localVue.use(VueApollo); + return createMockApollo([], mockResolvers); +} + +describe('ZentaoIssuesListRoot', () => { + let wrapper; + let mock; + + const mockSearchTerm = 'test issue'; + const mockLabel = 'ecosystem'; + + const findIssuableList = () => wrapper.findComponent(IssuableList); + const createLabelFilterEvent = (data) => ({ type: 'labels', value: { data } }); + const createSearchFilterEvent = (data) => ({ type: 'filtered-search-term', value: { data } }); + + const createComponent = ({ + apolloProvider = createMockApolloProvider(), + provide = mockProvide, + initialFilterParams = {}, + } = {}) => { + wrapper = shallowMount(ExternalIssuesListRoot, { + propsData: { + initialFilterParams, + }, + provide, + localVue, + apolloProvider, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('while loading', () => { + it('sets issuesListLoading to `true`', async () => { + jest.spyOn(axios, 'get').mockResolvedValue(new Promise(() => {})); + + createComponent(); + await wrapper.vm.$nextTick(); + + const issuableList = findIssuableList(); + expect(issuableList.props('issuablesLoading')).toBe(true); + }); + + it('calls `axios.get` with `issuesFetchPath` and query params', async () => { + jest.spyOn(axios, 'get'); + + createComponent(); + await waitForPromises(); + + expect(axios.get).toHaveBeenCalledWith( + mockProvide.issuesFetchPath, + expect.objectContaining({ + params: { + page: wrapper.vm.currentPage, + limit: wrapper.vm.$options.defaultPageSize, + state: wrapper.vm.currentState, + sort: wrapper.vm.sortedBy, + search: wrapper.vm.filterParams.search, + }, + }), + ); + }); + }); + + describe('with `initialFilterParams` prop', () => { + beforeEach(async () => { + jest.spyOn(axios, 'get').mockResolvedValue(resolvedValue); + + createComponent({ + initialFilterParams: { + labels: [mockLabel], + search: mockSearchTerm, + }, + }); + await waitForPromises(); + }); + + it('renders issuable-list component with correct props', () => { + const issuableList = findIssuableList(); + + expect(issuableList.props('initialFilterValue')).toEqual([ + { type: 'labels', value: { data: mockLabel } }, + { type: 'filtered-search-term', value: { data: mockSearchTerm } }, + ]); + expect(issuableList.props('urlParams').search).toBe(mockSearchTerm); + }); + }); + + describe('when request succeeds', () => { + beforeEach(async () => { + jest.spyOn(axios, 'get').mockResolvedValue(resolvedValue); + + createComponent(); + await waitForPromises(); + }); + + it('renders issuable-list component with correct props', () => { + const issuableList = findIssuableList(); + expect(issuableList.exists()).toBe(true); + expect(issuableList.props()).toMatchSnapshot(); + }); + + describe('issuable-list events', () => { + it('"click-tab" event executes GET request correctly', async () => { + const issuableList = findIssuableList(); + + issuableList.vm.$emit('click-tab', 'closed'); + await waitForPromises(); + + expect(axios.get).toHaveBeenCalledWith(mockProvide.issuesFetchPath, { + params: { + labels: undefined, + page: 1, + limit: 2, + search: undefined, + sort: 'created_desc', + state: 'closed', + }, + }); + expect(issuableList.props('currentTab')).toBe('closed'); + }); + + it('"page-change" event executes GET request correctly', async () => { + const mockPage = 2; + const issuableList = findIssuableList(); + jest.spyOn(axios, 'get').mockResolvedValue({ + ...resolvedValue, + headers: { 'x-page': mockPage, 'x-total': mockZentaoIssues.length }, + }); + + issuableList.vm.$emit('page-change', mockPage); + await waitForPromises(); + + expect(axios.get).toHaveBeenCalledWith(mockProvide.issuesFetchPath, { + params: { + labels: undefined, + page: mockPage, + limit: 2, + search: undefined, + sort: 'created_desc', + state: 'opened', + }, + }); + + await wrapper.vm.$nextTick(); + expect(issuableList.props()).toMatchObject({ + currentPage: mockPage, + previousPage: mockPage - 1, + nextPage: mockPage + 1, + }); + }); + + it('"sort" event executes GET request correctly', async () => { + const mockSortBy = 'updated_asc'; + const issuableList = findIssuableList(); + + issuableList.vm.$emit('sort', mockSortBy); + await waitForPromises(); + + expect(axios.get).toHaveBeenCalledWith(mockProvide.issuesFetchPath, { + params: { + labels: undefined, + page: 1, + limit: 2, + search: undefined, + sort: 'created_desc', + state: 'opened', + }, + }); + expect(issuableList.props('initialSortBy')).toBe(mockSortBy); + }); + + it.each` + desc | input | expected + ${'with label and search'} | ${[createLabelFilterEvent(mockLabel), createSearchFilterEvent(mockSearchTerm)]} | ${{ labels: [mockLabel], search: mockSearchTerm }} + ${'with multiple lables'} | ${[createLabelFilterEvent('label1'), createLabelFilterEvent('label2')]} | ${{ labels: ['label1', 'label2'], search: undefined }} + ${'with multiple searches'} | ${[createSearchFilterEvent('foo bar'), createSearchFilterEvent('lorem')]} | ${{ labels: undefined, search: 'foo bar lorem' }} + `( + '$desc, filter event sets "filterParams" value and calls fetchIssues', + async ({ input, expected }) => { + const issuableList = findIssuableList(); + + issuableList.vm.$emit('filter', input); + await waitForPromises(); + + expect(axios.get).toHaveBeenCalledWith(mockProvide.issuesFetchPath, { + params: { + page: 1, + limit: 2, + sort: 'created_desc', + state: 'opened', + ...expected, + }, + }); + }, + ); + }); + }); + + describe('error handling', () => { + describe('when request fails', () => { + it.each` + APIErrors | expectedRenderedErrorMessage + ${['API error']} | ${'API error'} + ${undefined} | ${ISSUES_LIST_FETCH_ERROR} + `( + 'calls `createFlash` with "$expectedRenderedErrorMessage" when API responds with "$APIErrors"', + async ({ APIErrors, expectedRenderedErrorMessage }) => { + jest.spyOn(axios, 'get'); + mock + .onGet(mockProvide.issuesFetchPath) + .replyOnce(httpStatus.INTERNAL_SERVER_ERROR, { errors: APIErrors }); + + createComponent(); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: expectedRenderedErrorMessage, + captureError: true, + error: expect.any(Object), + }); + }, + ); + }); + + describe('when GraphQL network error is encountered', () => { + it('calls `createFlash` correctly with default error message', async () => { + createComponent({ + apolloProvider: createMockApolloProvider({ + Query: { + externalIssues: jest.fn().mockRejectedValue(new Error('GraphQL networkError')), + }, + }), + }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: ISSUES_LIST_FETCH_ERROR, + captureError: true, + error: expect.any(Object), + }); + }); + }); + }); + + describe('pagination', () => { + it.each` + scenario | issuesListLoadFailed | issues | shouldShowPaginationControls + ${'fails'} | ${true} | ${[]} | ${false} + ${'returns no issues'} | ${false} | ${[]} | ${false} + ${`returns some issues`} | ${false} | ${mockZentaoIssues} | ${true} + `( + 'sets `showPaginationControls` prop to $shouldShowPaginationControls when request $scenario', + async ({ issuesListLoadFailed, issues, shouldShowPaginationControls }) => { + jest.spyOn(axios, 'get'); + mock + .onGet(mockProvide.issuesFetchPath) + .replyOnce( + issuesListLoadFailed ? httpStatus.INTERNAL_SERVER_ERROR : httpStatus.OK, + issues, + { + 'x-page': 1, + 'x-total': issues.length, + }, + ); + + createComponent(); + await waitForPromises(); + + expect(findIssuableList().props('showPaginationControls')).toBe( + shouldShowPaginationControls, + ); + }, + ); + }); +}); diff --git a/spec/frontend/integrations/zentao/issues_list/mock_data.js b/spec/frontend/integrations/zentao/issues_list/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..f29efd472ab6fac3e1f343c0fbe9c100f2713861 --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_list/mock_data.js @@ -0,0 +1,106 @@ +import getIssuesQuery from '~/integrations/zentao/issues_list/graphql/queries/get_zentao_issues.query.graphql'; +import { s__ } from '~/locale'; + +export const mockProvide = { + initialState: 'opened', + initialSortBy: 'created_desc', + page: 1, + issuesFetchPath: '/gitlab-org/gitlab-test/-/integrations/zentao/issues.json', + projectFullPath: 'gitlab-org/gitlab-test', + issueCreateUrl: 'https://gitlab-zentao.atlassian.net/secure/CreateIssue!default.jspa', + emptyStatePath: '/assets/illustrations/issues.svg', + getIssuesQuery, + externalIssuesLogo: ``, + externalIssueName: 'Zentao', + searchInputPlaceholderText: s__('Integrations|Search Zentao issues'), + recentSearchesStorageKey: 'zentao_issues', + createNewIssueText: s__('Integrations|Create new issue in Zentao'), + logoContainerClass: 'logo-container', + emptyStateNoIssueText: s__( + 'Integrations|Zentao issues display here when you create issues in your project in Zentao.', + ), +}; + +export const mockZentaoIssue1 = { + project_id: 1, + id: 1, + title: 'Eius fuga voluptates.', + created_at: '2020-03-19T14:31:51.281Z', + updated_at: '2020-10-20T07:01:45.865Z', + closed_at: null, + status: 'Selected for Development', + labels: [ + { + title: 'backend', + name: 'backend', + color: '#0052CC', + text_color: '#FFFFFF', + }, + ], + author: { + name: 'jhope', + web_url: 'https://gitlab-zentao.atlassian.net/people/5e32f803e127810e82875bc1', + avatar_url: null, + }, + assignees: [ + { + name: 'Kushal Pandya', + web_url: 'https://gitlab-zentao.atlassian.net/people/1920938475', + avatar_url: null, + }, + ], + web_url: 'https://gitlab-zentao.atlassian.net/browse/IG-31596', + gitlab_web_url: '', + references: { + relative: 'IG-31596', + }, + external_tracker: 'zentao', +}; + +export const mockZentaoIssue2 = { + project_id: 1, + id: 2, + title: 'Hic sit sint ducimus ea et sint.', + created_at: '2020-03-19T14:31:50.677Z', + updated_at: '2020-03-19T14:31:50.677Z', + closed_at: null, + status: 'Backlog', + labels: [], + author: { + name: 'Gabe Weaver', + web_url: 'https://gitlab-zentao.atlassian.net/people/5e320a31fe03e20c9d1dccde', + avatar_url: null, + }, + assignees: [], + web_url: 'https://gitlab-zentao.atlassian.net/browse/IG-31595', + gitlab_web_url: '', + references: { + relative: 'IG-31595', + }, + external_tracker: 'zentao', +}; + +export const mockZentaoIssue3 = { + project_id: 1, + id: 3, + title: 'Alias ut modi est labore.', + created_at: '2020-03-19T14:31:50.012Z', + updated_at: '2020-03-19T14:31:50.012Z', + closed_at: null, + status: 'Backlog', + labels: [], + author: { + name: 'Gabe Weaver', + web_url: 'https://gitlab-zentao.atlassian.net/people/5e320a31fe03e20c9d1dccde', + avatar_url: null, + }, + assignees: [], + web_url: 'https://gitlab-zentao.atlassian.net/browse/IG-31594', + gitlab_web_url: '', + references: { + relative: 'IG-31594', + }, + external_tracker: 'zentao', +}; + +export const mockZentaoIssues = [mockZentaoIssue1, mockZentaoIssue2, mockZentaoIssue3]; diff --git a/spec/frontend/integrations/zentao/issues_show/components/sidebar/__snapshots__/assignee_spec.js.snap b/spec/frontend/integrations/zentao/issues_show/components/sidebar/__snapshots__/assignee_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..0c3b6bc14c977c7499c9e075e2b79f1b36b292c9 --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/components/sidebar/__snapshots__/assignee_spec.js.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ZentaoIssuesSidebarAssignee with assignee template renders avatar components 1`] = ` +
+
+ + + + + +
+ + +
+`; + +exports[`ZentaoIssuesSidebarAssignee with no assignee template renders template without avatar components (the "None" state) 1`] = ` +
+
+ + + + None + +
+ + +
+`; diff --git a/spec/frontend/integrations/zentao/issues_show/components/sidebar/assignee_spec.js b/spec/frontend/integrations/zentao/issues_show/components/sidebar/assignee_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1013cbbf025f81bebe3746fdf61c609e9ecf94a5 --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/components/sidebar/assignee_spec.js @@ -0,0 +1,124 @@ +import { GlAvatarLabeled, GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import Assignee from '~/integrations/external_issue/issues_show/components/sidebar/assignee.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue'; + +import { mockZentaoIssue, mockZentaoIssuesProvides } from '../../mock_data'; + +const mockAssignee = convertObjectPropsToCamelCase(mockZentaoIssue.assignees[0], { deep: true }); + +describe('ZentaoIssuesSidebarAssignee', () => { + let wrapper; + + const findNoAssigneeText = () => wrapper.findByTestId('no-assignee-text'); + const findNoAssigneeIcon = () => wrapper.findByTestId('no-assignee-text'); + const findAvatar = () => wrapper.find(GlAvatar); + const findAvatarLabeled = () => wrapper.find(GlAvatarLabeled); + const findAvatarLink = () => wrapper.find(GlAvatarLink); + const findSidebarCollapsedIconWrapper = () => + wrapper.findByTestId('sidebar-collapsed-icon-wrapper'); + + const createComponent = ({ assignee } = {}) => { + wrapper = extendedWrapper( + shallowMount(Assignee, { + propsData: { + assignee, + }, + provide: { + userTypeText: mockZentaoIssuesProvides.userTypeText, + }, + }), + ); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('with assignee', () => { + beforeEach(() => { + createComponent({ assignee: mockAssignee }); + }); + + describe('template', () => { + it('renders avatar components', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders GlAvatarLink with correct props', () => { + const avatarLink = findAvatarLink(); + + expect(avatarLink.exists()).toBe(true); + expect(avatarLink.attributes()).toMatchObject({ + href: mockAssignee.webUrl, + title: mockAssignee.name, + }); + }); + + it('renders GlAvatarLabeled with correct props', () => { + const avatarLabeled = findAvatarLabeled(); + + expect(avatarLabeled.exists()).toBe(true); + expect(avatarLabeled.attributes()).toMatchObject({ + src: mockAssignee.avatarUrl, + alt: mockAssignee.name, + 'entity-name': mockAssignee.name, + }); + expect(avatarLabeled.props('label')).toBe(mockAssignee.name); + }); + + it('renders GlAvatar with correct props', () => { + const avatar = findAvatar(); + + expect(avatar.exists()).toBe(true); + expect(avatar.attributes()).toMatchObject({ + src: mockAssignee.avatarUrl, + alt: mockAssignee.name, + }); + expect(avatar.props('entityName')).toBe(mockAssignee.name); + }); + + it('renders AssigneeTitle with correct props', () => { + const title = wrapper.find(AssigneeTitle); + + expect(title.exists()).toBe(true); + expect(title.props('numberOfAssignees')).toBe(1); + }); + + it('does not render "No assignee" text', () => { + expect(findNoAssigneeText().exists()).toBe(false); + }); + + it('does not render "No assignee" icon', () => { + expect(findNoAssigneeIcon().exists()).toBe(false); + }); + + it('sets `title` attribute of collapsed sidebar wrapper correctly', () => { + const iconWrapper = findSidebarCollapsedIconWrapper(); + expect(iconWrapper.attributes('title')).toBe(mockAssignee.name); + }); + }); + }); + + describe('with no assignee', () => { + beforeEach(() => { + createComponent({ assignee: undefined }); + }); + + describe('template', () => { + it('renders template without avatar components (the "None" state)', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('sets `title` attribute of collapsed sidebar wrapper correctly', () => { + const iconWrapper = findSidebarCollapsedIconWrapper(); + expect(iconWrapper.attributes('title')).toBe('No assignee'); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/zentao/issues_show/components/sidebar/issue_due_date_spec.js b/spec/frontend/integrations/zentao/issues_show/components/sidebar/issue_due_date_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..6281972c1f245a80f853042735b66f41981186b2 --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/components/sidebar/issue_due_date_spec.js @@ -0,0 +1,69 @@ +import { shallowMount } from '@vue/test-utils'; + +import { useFakeDate } from 'helpers/fake_date'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +import IssueDueDate from '~/integrations/external_issue/issues_show/components/sidebar/issue_due_date.vue'; + +describe('IssueDueDate', () => { + let wrapper; + + const createComponent = ({ props = {} } = {}) => { + wrapper = extendedWrapper( + shallowMount(IssueDueDate, { + propsData: props, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findDueDateCollapsed = () => wrapper.findByTestId('due-date-collapsed'); + const findDueDateValue = () => wrapper.findByTestId('due-date-value'); + + describe('when dueDate is null', () => { + it('renders "None" as value', () => { + createComponent(); + + expect(findDueDateCollapsed().text()).toBe('None'); + expect(findDueDateValue().text()).toBe('None'); + }); + }); + + describe('when dueDate is in the past', () => { + const dueDate = '2021-02-14T00:00:00.000Z'; + + useFakeDate(2021, 2, 18); + + it('renders formatted dueDate', () => { + createComponent({ + props: { + dueDate, + }, + }); + + expect(findDueDateCollapsed().text()).toBe('Feb 14, 2021'); + expect(findDueDateValue().text()).toBe('Feb 14, 2021 (Past due)'); + }); + }); + + describe('when dueDate is in the future', () => { + const dueDate = '2021-02-14T00:00:00.000Z'; + + useFakeDate(2020, 12, 20); + + it('renders formatted dueDate', () => { + createComponent({ + props: { + dueDate, + }, + }); + + expect(findDueDateCollapsed().text()).toBe('Feb 14, 2021'); + expect(findDueDateValue().text()).toBe('Feb 14, 2021'); + }); + }); +}); diff --git a/spec/frontend/integrations/zentao/issues_show/components/sidebar/issue_field_dropdown_spec.js b/spec/frontend/integrations/zentao/issues_show/components/sidebar/issue_field_dropdown_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c1fbeb062aa36d7a4ac77f0c095b967fe507ee34 --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/components/sidebar/issue_field_dropdown_spec.js @@ -0,0 +1,57 @@ +import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import IssueFieldDropdown from '~/integrations/external_issue/issues_show/components/sidebar/issue_field_dropdown.vue'; + +import { mockZentaoIssueStatuses } from '../../mock_data'; + +describe('IssueFieldDropdown', () => { + let wrapper; + + const emptyText = 'empty text'; + const defaultProps = { + emptyText, + text: 'issue field text', + title: 'issue field header text', + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(IssueFieldDropdown, { + propsData: { ...defaultProps, ...props }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findAllGlDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + it.each` + loading | items + ${true} | ${[]} + ${true} | ${mockZentaoIssueStatuses} + ${false} | ${[]} + ${false} | ${mockZentaoIssueStatuses} + `('with loading = $loading, items = $items', ({ loading, items }) => { + createComponent({ + props: { + loading, + items, + }, + }); + + expect(findGlLoadingIcon().exists()).toBe(loading); + + if (!loading) { + if (items.length) { + findAllGlDropdownItems().wrappers.forEach((itemWrapper, index) => { + expect(itemWrapper.text()).toBe(mockZentaoIssueStatuses[index].title); + }); + } else { + expect(wrapper.text()).toBe(emptyText); + } + } + }); +}); diff --git a/spec/frontend/integrations/zentao/issues_show/components/sidebar/issue_field_spec.js b/spec/frontend/integrations/zentao/issues_show/components/sidebar/issue_field_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..99a300ecd6af2afcd174b484481cf029e36e4c2b --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/components/sidebar/issue_field_spec.js @@ -0,0 +1,117 @@ +import { GlButton, GlIcon } from '@gitlab/ui'; + +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import IssueField from '~/integrations/external_issue/issues_show/components/sidebar/issue_field.vue'; + +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; + +describe('IssueField', () => { + let wrapper; + + const defaultProps = { + icon: 'calendar', + title: 'Field Title', + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(IssueField, { + directives: { + GlTooltip: createMockDirective(), + }, + propsData: { ...defaultProps, ...props }, + stubs: { + SidebarEditableItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findEditButton = () => wrapper.findComponent(GlButton); + const findFieldCollapsed = () => wrapper.findByTestId('field-collapsed'); + const findFieldCollapsedTooltip = () => getBinding(findFieldCollapsed().element, 'gl-tooltip'); + const findFieldValue = () => wrapper.findByTestId('field-value'); + const findGlIcon = () => wrapper.findComponent(GlIcon); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders title', () => { + expect(findEditableItem().props('title')).toBe(defaultProps.title); + }); + + it('renders GlIcon (when collapsed)', () => { + expect(findGlIcon().props('name')).toBe(defaultProps.icon); + }); + + it('does not render "Edit" button', () => { + expect(findEditButton().exists()).toBe(false); + }); + }); + + describe('without value prop', () => { + beforeEach(() => { + createComponent(); + }); + + it('falls back to "None"', () => { + expect(findFieldValue().text()).toBe('None'); + }); + + it('renders tooltip (when collapsed) with "value" = title', () => { + const tooltip = findFieldCollapsedTooltip(); + + expect(tooltip).toBeDefined(); + expect(tooltip.value.title).toBe(defaultProps.title); + }); + }); + + describe('with value prop', () => { + const value = 'field value'; + + beforeEach(() => { + createComponent({ + props: { value }, + }); + }); + + it('renders the value', () => { + expect(findFieldValue().text()).toBe(value); + }); + + it('renders tooltip (when collapsed) with "value" = value', () => { + const tooltip = findFieldCollapsedTooltip(); + + expect(tooltip).toBeDefined(); + expect(tooltip.value.title).toBe(value); + }); + }); + + describe('with canUpdate = true', () => { + beforeEach(() => { + createComponent({ + props: { canUpdate: true }, + }); + }); + + it('renders "Edit" button', () => { + expect(findEditButton().text()).toBe('Edit'); + }); + + it('emits "issue-field-fetch" when dropdown is opened', () => { + wrapper.vm.$refs.dropdown.showDropdown = jest.fn(); + + findEditableItem().vm.$emit('open'); + + expect(wrapper.vm.$refs.dropdown.showDropdown).toHaveBeenCalled(); + expect(wrapper.emitted('issue-field-fetch')).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/integrations/zentao/issues_show/components/sidebar/zentao_issues_sidebar_root_spec.js b/spec/frontend/integrations/zentao/issues_show/components/sidebar/zentao_issues_sidebar_root_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..04ddf9922b0c181664af156cdb9bde3bebf7adcb --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/components/sidebar/zentao_issues_sidebar_root_spec.js @@ -0,0 +1,64 @@ +import { shallowMount } from '@vue/test-utils'; +import Assignee from '~/integrations/external_issue/issues_show/components/sidebar/assignee.vue'; +import Sidebar from '~/integrations/external_issue/issues_show/components/sidebar/external_issues_sidebar_root.vue'; +import IssueDueDate from '~/integrations/external_issue/issues_show/components/sidebar/issue_due_date.vue'; +import IssueField from '~/integrations/external_issue/issues_show/components/sidebar/issue_field.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; +import { mockZentaoIssue as mockZentaoIssueData, mockZentaoIssuesProvides } from '../../mock_data'; + +const mockZentaoIssue = convertObjectPropsToCamelCase(mockZentaoIssueData, { deep: true }); + +describe('ZentaoIssuesSidebar', () => { + let wrapper; + + const defaultProps = { + sidebarExpanded: false, + issue: mockZentaoIssue, + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(Sidebar, { + propsData: { ...defaultProps, ...props }, + provide: mockZentaoIssuesProvides, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findLabelsSelect = () => wrapper.findComponent(LabelsSelect); + const findAssignee = () => wrapper.findComponent(Assignee); + const findIssueDueDate = () => wrapper.findComponent(IssueDueDate); + const findIssueField = () => wrapper.findComponent(IssueField); + + it('renders Labels block', () => { + createComponent(); + + expect(findLabelsSelect().props('selectedLabels')).toBe(mockZentaoIssue.labels); + }); + + it('renders Assignee block', () => { + createComponent(); + const assignee = findAssignee(); + + expect(assignee.props('assignee')).toBe(mockZentaoIssue.assignees[0]); + }); + + it('renders IssueDueDate', () => { + createComponent(); + const dueDate = findIssueDueDate(); + + expect(dueDate.props('dueDate')).toBe(mockZentaoIssue.dueDate); + }); + + it('renders IssueField', () => { + createComponent(); + const field = findIssueField(); + + expect(field.props('icon')).toBe('progress'); + expect(field.props('title')).toBe('Status'); + expect(field.props('value')).toBe(mockZentaoIssue.status); + }); +}); diff --git a/spec/frontend/integrations/zentao/issues_show/components/zentao_issues_show_root_spec.js b/spec/frontend/integrations/zentao/issues_show/components/zentao_issues_show_root_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b542a3f5ae06288528a3515ee3da3248aed55934 --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/components/zentao_issues_show_root_spec.js @@ -0,0 +1,185 @@ +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; + +import waitForPromises from 'helpers/wait_for_promises'; +import * as ZentaoIssuesShowApi from '~/integrations/external_issue/issues_show/api'; +import ZentaoIssuesShow from '~/integrations/external_issue/issues_show/components/external_issues_show_root.vue'; +import ZentaoIssueSidebar from '~/integrations/external_issue/issues_show/components/sidebar/external_issues_sidebar_root.vue'; +import { issueStates } from '~/integrations/external_issue/issues_show/constants'; +import IssuableHeader from '~/issuable_show/components/issuable_header.vue'; +import IssuableShow from '~/issuable_show/components/issuable_show_root.vue'; +import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; +import { mockZentaoIssue, mockZentaoIssuesProvides } from '../mock_data'; + +const mockZentaoIssuesShowPath = 'zentao_issues_show_path'; + +describe('ZentaoIssuesShow', () => { + let wrapper; + let mockAxios; + + const findGlAlert = () => wrapper.findComponent(GlAlert); + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findIssuableShow = () => wrapper.findComponent(IssuableShow); + const findZentaoIssueSidebar = () => wrapper.findComponent(ZentaoIssueSidebar); + const findIssuableShowStatusBadge = () => + wrapper.findComponent(IssuableHeader).find('[data-testid="status"]'); + + const createComponent = () => { + wrapper = shallowMount(ZentaoIssuesShow, { + stubs: { + IssuableHeader, + IssuableShow, + IssuableSidebar, + }, + provide: { + issuesShowPath: mockZentaoIssuesShowPath, + ...mockZentaoIssuesProvides, + }, + }); + }; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + mockAxios.restore(); + wrapper.destroy(); + }); + + describe('when issue is loading', () => { + it('renders GlLoadingIcon', () => { + createComponent(); + + expect(findGlLoadingIcon().exists()).toBe(true); + expect(findGlAlert().exists()).toBe(false); + expect(findIssuableShow().exists()).toBe(false); + }); + }); + + describe('when error occurs during fetch', () => { + it('renders error message', async () => { + mockAxios.onGet(mockZentaoIssuesShowPath).replyOnce(500); + createComponent(); + + await waitForPromises(); + + const alert = findGlAlert(); + + expect(findGlLoadingIcon().exists()).toBe(false); + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe( + s__( + 'ZenTaoIntegration|Failed to load ZenTao issue. View the issue in ZenTao, or reload the page.', + ), + ); + expect(alert.props('variant')).toBe('danger'); + expect(findIssuableShow().exists()).toBe(false); + }); + }); + + it('renders IssuableShow', async () => { + mockAxios.onGet(mockZentaoIssuesShowPath).replyOnce(200, mockZentaoIssue); + createComponent(); + + await waitForPromises(); + + expect(findGlLoadingIcon().exists()).toBe(false); + expect(findIssuableShow().exists()).toBe(true); + }); + + describe.each` + state | statusIcon | statusBadgeClass | badgeText + ${issueStates.OPENED} | ${'issue-open-m'} | ${'status-box-open'} | ${'Open'} + ${issueStates.CLOSED} | ${'mobile-issue-close'} | ${'status-box-issue-closed'} | ${'Closed'} + `('when issue state is `$state`', ({ state, statusIcon, statusBadgeClass, badgeText }) => { + beforeEach(async () => { + mockAxios.onGet(mockZentaoIssuesShowPath).replyOnce(200, { ...mockZentaoIssue, state }); + createComponent(); + + await waitForPromises(); + }); + + it('sets `statusIcon` prop correctly', () => { + expect(findIssuableShow().props('statusIcon')).toBe(statusIcon); + }); + + it('sets `statusBadgeClass` prop correctly', () => { + expect(findIssuableShow().props('statusBadgeClass')).toBe(statusBadgeClass); + }); + + it('renders correct status badge text', () => { + expect(findIssuableShowStatusBadge().text()).toBe(badgeText); + }); + }); + + describe('ZentaoIssueSidebar events', () => { + beforeEach(async () => { + mockAxios.onGet(mockZentaoIssuesShowPath).replyOnce(200, mockZentaoIssue); + createComponent(); + + await waitForPromises(); + }); + + it('updates issue labels on issue-labels-updated', async () => { + const updateIssueSpy = jest.spyOn(ZentaoIssuesShowApi, 'updateIssue').mockResolvedValue(); + + const labels = [{ id: 'ecosystem' }]; + + findZentaoIssueSidebar().vm.$emit('issue-labels-updated', labels); + await wrapper.vm.$nextTick(); + + expect(updateIssueSpy).toHaveBeenCalledWith(expect.any(Object), { labels }); + expect(findZentaoIssueSidebar().props('isUpdatingLabels')).toBe(true); + + await waitForPromises(); + + expect(findZentaoIssueSidebar().props('isUpdatingLabels')).toBe(false); + }); + + it('fetches issue statuses on issue-status-fetch', async () => { + const fetchIssueStatusesSpy = jest + .spyOn(ZentaoIssuesShowApi, 'fetchIssueStatuses') + .mockResolvedValue(); + + findZentaoIssueSidebar().vm.$emit('issue-status-fetch'); + await wrapper.vm.$nextTick(); + + expect(fetchIssueStatusesSpy).toHaveBeenCalled(); + expect(findZentaoIssueSidebar().props('isLoadingStatus')).toBe(true); + + await waitForPromises(); + + expect(findZentaoIssueSidebar().props('isLoadingStatus')).toBe(false); + }); + + it('updates issue status on issue-status-updated', async () => { + const updateIssueSpy = jest.spyOn(ZentaoIssuesShowApi, 'updateIssue').mockResolvedValue(); + + const status = 'In Review'; + + findZentaoIssueSidebar().vm.$emit('issue-status-updated', status); + await wrapper.vm.$nextTick(); + + expect(updateIssueSpy).toHaveBeenCalledWith(expect.any(Object), { status }); + expect(findZentaoIssueSidebar().props('isUpdatingStatus')).toBe(true); + + await waitForPromises(); + + expect(findZentaoIssueSidebar().props('isUpdatingStatus')).toBe(false); + }); + + it('updates `sidebarExpanded` prop on `sidebar-toggle` event', async () => { + const zentaoIssueSidebar = findZentaoIssueSidebar(); + expect(zentaoIssueSidebar.props('sidebarExpanded')).toBe(true); + + zentaoIssueSidebar.vm.$emit('sidebar-toggle'); + await wrapper.vm.$nextTick(); + + expect(zentaoIssueSidebar.props('sidebarExpanded')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/integrations/zentao/issues_show/mock_data.js b/spec/frontend/integrations/zentao/issues_show/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..394dcc39a44374e1fec8b981b0535258bdc0c2d4 --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/mock_data.js @@ -0,0 +1,71 @@ +import { s__, __ } from '~/locale'; + +export const mockZentaoIssue = { + title: 'FE-2 The second FE issue on Zentao', + description_html: + 'FE-2 The second FE issue on Zentao', + created_at: '"2021-02-01T04:04:40.833Z"', + author: { + name: 'Justin Ho', + web_url: 'http://127.0.0.1:3000/root', + avatar_url: 'http://127.0.0.1:3000/uploads/-/system/user/avatar/1/avatar.png?width=90', + }, + assignees: [ + { + name: 'Justin Ho', + web_url: 'http://127.0.0.1:3000/root', + avatar_url: 'http://127.0.0.1:3000/uploads/-/system/user/avatar/1/avatar.png?width=90', + }, + ], + due_date: '2021-02-14T00:00:00.000Z', + labels: [ + { + title: 'In Progress', + description: 'Work that is still in progress', + color: '#0052CC', + text_color: '#FFFFFF', + }, + ], + references: { + relative: 'FE-2', + }, + state: 'opened', + status: 'In Progress', +}; + +export const mockZentaoIssueComment = { + body_html: '

hi

', + created_at: '"2021-02-01T04:04:40.833Z"', + author: { + name: 'Justin Ho', + web_url: 'http://127.0.0.1:3000/root', + avatar_url: 'http://127.0.0.1:3000/uploads/-/system/user/avatar/1/avatar.png?width=90', + }, + id: 10000, +}; + +export const mockZentaoIssueStatuses = [{ title: 'In Progress' }, { title: 'Done' }]; + +export const mockZentaoIssuesProvides = { + failFetchingIssueText: s__( + 'ZenTaoIntegration|Failed to load ZenTao issue. View the issue in ZenTao, or reload the page.', + ), + failFetchingIssueStatusText: s__( + 'ZenTaoIntegration|Failed to load ZenTao issue. View the issue in ZenTao, or reload the page.', + ), + failUpdatingIssueLabelsText: s__( + 'ZenTaoIntegration|Failed to update ZenTao issue labels. View the issue in ZenTao, or reload the page.', + ), + failUpdatingIssueStatusText: s__( + 'ZenTaoIntegration|Failed to update ZenTao issue status. View the issue in ZenTao, or reload the page.', + ), + featureFlagCanEditLabelKey: 'zentaoIssueDetailsEditLabels', + featureFlagCanEditStatusKey: 'zentaoIssueDetailsEditStatus', + seeMoreDetailText: s__( + `ZenTaoIntegration|Not all data may be displayed here. To view more details or make changes to this issue, go to %{linkStart}ZenTao%{linkEnd}.`, + ), + statusDropdownEmptyText: s__('ZenTaoIntegration|No available statuses'), + titleText: s__('ZenTaoIntegration|This issue is synchronized with ZenTao'), + userTypeText: __('ZenTao user'), + userTypeTooltipText: __('This is a ZenTao user.'), +};