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/zentao/issues_list/components/zentao_issues_list_empty_state.vue b/app/assets/javascripts/integrations/zentao/issues_list/components/zentao_issues_list_empty_state.vue new file mode 100644 index 0000000000000000000000000000000000000000..7f915a4afe3e5029d6906c802d7b39452f007d93 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_list/components/zentao_issues_list_empty_state.vue @@ -0,0 +1,71 @@ + + + diff --git a/app/assets/javascripts/integrations/zentao/issues_list/components/zentao_issues_list_root.vue b/app/assets/javascripts/integrations/zentao/issues_list/components/zentao_issues_list_root.vue new file mode 100644 index 0000000000000000000000000000000000000000..023e9fc4e1aa1c2a30ec920a0a3fe10528c43a51 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_list/components/zentao_issues_list_root.vue @@ -0,0 +1,278 @@ + + + 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/index.js b/app/assets/javascripts/integrations/zentao/issues_list/graphql/index.js new file mode 100644 index 0000000000000000000000000000000000000000..c8721e86c0f6bc8608442a5dfcc1fa85859bc251 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_list/graphql/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import zentaoIssues from './resolvers/zentao_issues'; + +Vue.use(VueApollo); + +const resolvers = { + Query: { + zentaoIssues, + }, +}; + +const defaultClient = createDefaultClient(resolvers, { assumeImmutableResults: true }); + +export default new VueApollo({ + defaultClient, +}); 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..7f1c210f506dc5b6e1cb7607e0c4cff13a565ae8 --- /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 zentaoIssues( + $issuesFetchPath: String + $search: String + $labels: String + $sort: String + $state: String + $page: Integer +) { + zentaoIssues( + 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..c2dbfb3205c4dc22fbd2625ac1846c0b70b73368 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_list/zentao_issues_list_bundle.js @@ -0,0 +1,48 @@ +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 ZentaoIssuesListApp from './components/zentao_issues_list_root.vue'; +import apolloProvider from './graphql'; + +export default function initZentaoIssuesList({ 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, + }, + apolloProvider, + render: (createElement) => + createElement(ZentaoIssuesListApp, { + props: { + initialFilterParams, + }, + }), + }); +} diff --git a/app/assets/javascripts/integrations/zentao/issues_show/api.js b/app/assets/javascripts/integrations/zentao/issues_show/api.js new file mode 100644 index 0000000000000000000000000000000000000000..95fe28c90e230689af73714287453ee9c85c537d --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/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/zentao/issues_show/components/note.vue b/app/assets/javascripts/integrations/zentao/issues_show/components/note.vue new file mode 100644 index 0000000000000000000000000000000000000000..ff7fbd75ad4b056d74be47fdd8ec313bafba817e --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_show/components/note.vue @@ -0,0 +1,100 @@ + + + diff --git a/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/assignee.vue b/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/assignee.vue new file mode 100644 index 0000000000000000000000000000000000000000..1c410c8ce1c1903f3bb284d68dccef4bd1fbed48 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/assignee.vue @@ -0,0 +1,85 @@ + + + diff --git a/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/issue_due_date.vue b/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/issue_due_date.vue new file mode 100644 index 0000000000000000000000000000000000000000..66360feda1abfc4b0f9772d2eca2cf24fa7a95ae --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/issue_due_date.vue @@ -0,0 +1,76 @@ + + + diff --git a/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/issue_field.vue b/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/issue_field.vue new file mode 100644 index 0000000000000000000000000000000000000000..2e24d77632f9a407501d12326cc5ec6887f8e303 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/issue_field.vue @@ -0,0 +1,141 @@ + + + diff --git a/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/issue_field_dropdown.vue b/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/issue_field_dropdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..37df42c80e5a52c8484ed23b8cff77737d83ed04 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/issue_field_dropdown.vue @@ -0,0 +1,66 @@ + + + diff --git a/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/zentao_issues_sidebar_root.vue b/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/zentao_issues_sidebar_root.vue new file mode 100644 index 0000000000000000000000000000000000000000..8dd71e512b64ed34637123c15326b763b09e68a0 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_show/components/sidebar/zentao_issues_sidebar_root.vue @@ -0,0 +1,162 @@ + + + diff --git a/app/assets/javascripts/integrations/zentao/issues_show/components/zentao_issues_show_root.vue b/app/assets/javascripts/integrations/zentao/issues_show/components/zentao_issues_show_root.vue new file mode 100644 index 0000000000000000000000000000000000000000..b7a1a2cb98dc01100acb9219897afb49cfab0b27 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_show/components/zentao_issues_show_root.vue @@ -0,0 +1,190 @@ + + + diff --git a/app/assets/javascripts/integrations/zentao/issues_show/constants.js b/app/assets/javascripts/integrations/zentao/issues_show/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..29bb2dedfd2554649972bc02c93e426b19f7c994 --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/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/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..137de031eaced84d8c51541a3b38c00f3e34d28a --- /dev/null +++ b/app/assets/javascripts/integrations/zentao/issues_show/zentao_issues_show_bundle.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; + +import ZentaoIssuesShowApp from './components/zentao_issues_show_root.vue'; + +export default 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, + }, + render: (createElement) => createElement(ZentaoIssuesShowApp), + }); +} 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/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index 10122b4c77bc34db54f7100709c7514e00a1fb5d..5b6a064dea6e219517b65c038f4c7c364d095057 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -74,7 +74,9 @@ module Params :url, :user_key, :username, - :webhook + :webhook, + :api_token, + :zentao_product_xid ].freeze # Parameters to ignore if no value is specified diff --git a/app/controllers/projects/integrations/zentao/issues_controller.rb b/app/controllers/projects/integrations/zentao/issues_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..1dc21b4bb176465fb5379a024587ad5715863bb3 --- /dev/null +++ b/app/controllers/projects/integrations/zentao/issues_controller.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Projects + module Integrations + module Zentao + class IssuesController < Projects::ApplicationController + include RecordUserLastActivity + include RedisTracking + + track_redis_hll_event :index, name: 'i_ecosystem_zentao_integration_list_issues' + + before_action :check_feature_enabled! + + rescue_from ::Gitlab::Zentao::Client::ConfigError, with: :render_integration_error + rescue_from ::Gitlab::Zentao::Client::Error, with: :render_request_error + + feature_category :integrations + + def index + respond_to do |format| + format.html + format.json do + render json: issues_json + end + end + end + + def show + respond_to do |format| + format.html do + @issue_json = issue_json + end + format.json do + render json: issue_json + end + end + end + + private + + # for track data + def visitor_id + current_user&.id + end + + def check_feature_enabled! + return render_404 unless ::Integrations::Zentao.feature_flag_enabled? + + return render_404 unless project.zentao_issues_integration_available? && project.zentao_integration&.active? + end + + # Return the informational message to the user + def render_integration_error(exception) + log_exception(exception) + + render json: { errors: [exception.message] }, status: :bad_request + end + + # Log the specific request error details and return generic message + def render_request_error(exception) + log_exception(exception) + + render json: { errors: [_('An error occurred while requesting data from the Zentao service.')] }, status: :bad_request + end + + def issues_json + issues_response = finder.fetch_issues(finder_options) + issues = Kaminari.paginate_array( + issues_response['issues'], + limit: issues_response['limit'], + total_count: issues_response['total'] + ) + + ::Integrations::ZentaoSerializers::IssueSerializer.new + .with_pagination(request, response) + .represent(issues, project: project) + end + + def issue_json + issue_response = finder.fetch_issue(params[:id]) + ::Integrations::ZentaoSerializers::IssueDetailSerializer.new + .represent(issue_response['issue'], project: project) + end + + def finder + @finder ||= ::Gitlab::Zentao::Client.new(project.zentao_integration) + end + + def zentao_order + key, order = params['sort'].to_s.split('_', 2) + zentao_key = (key == 'created' ? 'openedDate' : 'lastEditedDate') + zentao_order = (order == 'asc' ? 'asc' : 'desc') + + "#{zentao_key}_#{zentao_order}" + end + + def finder_options + options = { + order: zentao_order, + status: params[:state].presence || 'opened', + labels: (params[:labels].presence || []).join(',') + } + + params.permit(:page, :limit, :search).merge(options) + end + end + end + end +end diff --git a/app/helpers/integrations_helper.rb b/app/helpers/integrations_helper.rb index d06746c06e3d981c5696842516157c57287e42a1..975b4698d56b7847f59b9f9d5cc4a9771257fd96 100644 --- a/app/helpers/integrations_helper.rb +++ b/app/helpers/integrations_helper.rb @@ -143,6 +143,20 @@ def jira_issue_breadcrumb_link(issue_reference) end end + def zentao_issue_breadcrumb_link(issue) + link_to issue[:web_url], { class: 'gl-display-flex gl-align-items-center gl-white-space-nowrap' } do + icon = image_tag image_path('logos/zentao.svg'), width: 15, height: 15, class: 'gl-mr-2' + [icon, issue[:id]].join.html_safe + end + end + + def zentao_issues_show_data + { + issues_show_path: project_integrations_zentao_issue_path(@project, params[:id], format: :json), + issues_list_path: project_integrations_zentao_issues_path(@project) + } + end + extend self private diff --git a/app/models/concerns/integrations/has_data_fields.rb b/app/models/concerns/integrations/has_data_fields.rb index e9aaaac8226300101178f17433acc396d9a0cd01..1709b56080eb5d01ebfbdaec77c3058eb51f59dd 100644 --- a/app/models/concerns/integrations/has_data_fields.rb +++ b/app/models/concerns/integrations/has_data_fields.rb @@ -46,6 +46,7 @@ def #{arg}_was has_one :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::IssueTrackerData' has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::JiraTrackerData' has_one :open_project_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::OpenProjectTrackerData' + has_one :zentao_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :integration_id, class_name: 'Integrations::ZentaoTrackerData' def data_fields raise NotImplementedError diff --git a/app/models/integration.rb b/app/models/integration.rb index 5c4d03f1fa80c1e3bac9f3adf280c4e21f614d64..73e8efadfd32d595456eaeb8171766b9b57a8e8a 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -209,6 +209,8 @@ def self.available_integration_names(include_project_specific: true, include_dev end def self.integration_names + return INTEGRATION_NAMES + %w[zentao] if ::Integrations::Zentao.feature_flag_enabled? + INTEGRATION_NAMES end diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb new file mode 100644 index 0000000000000000000000000000000000000000..95dbc2cb4d126e4fe5ed7efd63cdb1d9d7b6ff87 --- /dev/null +++ b/app/models/integrations/zentao.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Integrations + class Zentao < Integration + data_field :url, :api_url, :api_token, :zentao_product_xid + + validates :url, public_url: true, presence: true, if: :activated? + validates :api_url, public_url: true, allow_blank: true + validates :api_token, presence: true, if: :activated? + validates :zentao_product_xid, presence: true, if: :activated? + + def self.feature_flag_enabled? + Feature.enabled?(:integration_zentao_issues) + end + + def data_fields + zentao_tracker_data || self.build_zentao_tracker_data + end + + def title + self.class.name.demodulize + end + + def description + s_("ZentaoIntegration|Use Zentao to manage issues.") + end + + def self.to_param + name.demodulize.downcase + end + + def test(*args) + client.ping + end + + def self.supported_events + %w() + end + + def self.supported_event_actions + %w() + end + + def fields + [ + { + type: 'text', + name: 'url', + title: s_('ZentaoIntegration|Zentao Web URL'), + placeholder: 'https://www.zentao.net', + help: s_('ZentaoIntegration|Base URL of the Zentao instance.'), + required: true + }, + { + type: 'text', + name: 'api_url', + title: s_('ZentaoIntegration|Zentao API URL'), + help: s_('ZentaoIntegration|If different from Web URL.') + }, + { + type: 'password', + name: 'api_token', + title: s_('ZentaoIntegration|Zentao API token'), + non_empty_password_title: s_('ZentaoIntegration|Enter API token'), + required: true + }, + { + type: 'text', + name: 'zentao_product_xid', + title: s_('ZentaoIntegration|Zentao Product ID'), + required: true + } + ] + end + + private + + def client + @client ||= ::Gitlab::Zentao::Client.new(self) + end + end +end diff --git a/app/models/integrations/zentao_tracker_data.rb b/app/models/integrations/zentao_tracker_data.rb new file mode 100644 index 0000000000000000000000000000000000000000..90cd5221d3903990a5a7101d7b58c793a448285f --- /dev/null +++ b/app/models/integrations/zentao_tracker_data.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Integrations + class ZentaoTrackerData < ApplicationRecord + belongs_to :integration, inverse_of: self.table_name.to_sym, foreign_key: :integration_id + delegate :activated?, to: :integration + validates :integration, presence: true + + scope :encryption_options, -> do + { + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm', + encode: false, + encode_iv: false + } + end + + attr_encrypted :url, encryption_options + attr_encrypted :api_url, encryption_options + attr_encrypted :zentao_product_xid, encryption_options + attr_encrypted :api_token, encryption_options + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 85bb1dea48fa52058a2c68a57216054ff5c28c89..1616f3a498edf35aded55820cffda02cce185089 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -188,6 +188,7 @@ def self.integration_association_name(name) has_one :unify_circuit_integration, class_name: 'Integrations::UnifyCircuit' has_one :webex_teams_integration, class_name: 'Integrations::WebexTeams' has_one :youtrack_integration, class_name: 'Integrations::Youtrack' + has_one :zentao_integration, class_name: 'Integrations::Zentao' has_one :root_of_fork_network, foreign_key: 'root_project_id', diff --git a/app/serializers/integrations/zentao_serializers/issue_detail_entity.rb b/app/serializers/integrations/zentao_serializers/issue_detail_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..fe7d59fdcaf931a0e486e2291617e2f71dd8d26e --- /dev/null +++ b/app/serializers/integrations/zentao_serializers/issue_detail_entity.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Integrations + module ZentaoSerializers + class IssueDetailEntity < IssueEntity + expose :comments do |item| + [] + end + end + end +end diff --git a/app/serializers/integrations/zentao_serializers/issue_detail_serializer.rb b/app/serializers/integrations/zentao_serializers/issue_detail_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..72bd7e41b080e3e387daeb97c34ebef1c02555fe --- /dev/null +++ b/app/serializers/integrations/zentao_serializers/issue_detail_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Integrations + module ZentaoSerializers + class IssueDetailSerializer < BaseSerializer + entity ::Integrations::ZentaoSerializers::IssueDetailEntity + end + end +end diff --git a/app/serializers/integrations/zentao_serializers/issue_entity.rb b/app/serializers/integrations/zentao_serializers/issue_entity.rb new file mode 100644 index 0000000000000000000000000000000000000000..a3df7d63a01042c1b9807da4f7937b92b5691866 --- /dev/null +++ b/app/serializers/integrations/zentao_serializers/issue_entity.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Integrations + module ZentaoSerializers + class IssueEntity < Grape::Entity + include RequestAwareEntity + + expose :id do |item| + item['id'] + end + + expose :project_id do |item| + project.id + end + + expose :title do |item| + item['title'] + end + + expose :created_at do |item| + item['openedDate'] + end + + expose :updated_at do |item| + item['lastEditedDate'] + end + + expose :closed_at do |item| + end + + expose :status do |item| + item['status'] + end + + expose :labels do |item| + item['labels'].map do |name| + { + id: name, + title: name, + name: name, + color: '#0052CC', + text_color: '#FFFFFF' + } + end + end + + expose :author do |item| + user_info(item['openedBy']) + end + + expose :assignees do |item| + item['assignedTo'].map do |user| + user_info(user) + end + end + + expose :web_url do |item| + item['url'] + end + + expose :gitlab_web_url do |item| + project_integrations_zentao_issue_path(project, item['id']) + end + + private + + def project + @project ||= options[:project] + end + + def user_info(user) + { + "name": user['realname'].presence || user['account'], + "web_url": user['url'], + "avatar_url": user['avatar'] + } + end + end + end +end diff --git a/app/serializers/integrations/zentao_serializers/issue_serializer.rb b/app/serializers/integrations/zentao_serializers/issue_serializer.rb new file mode 100644 index 0000000000000000000000000000000000000000..064833554f290a0262c7ef622994fe88200af690 --- /dev/null +++ b/app/serializers/integrations/zentao_serializers/issue_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Integrations + module ZentaoSerializers + class IssueSerializer < BaseSerializer + include WithPagination + + entity ::Integrations::ZentaoSerializers::IssueEntity + end + end +end 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..52407d99f8fa09aab02300ab66b3d5bad148bbb3 --- /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..b3bd2a9168b9204b7d19dba60bfd63f96faf2f6c --- /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/config/feature_flags/development/integration_zentao_issues.yml b/config/feature_flags/development/integration_zentao_issues.yml new file mode 100644 index 0000000000000000000000000000000000000000..e0daba734acaa4f7746bf8dd670beb13106446bf --- /dev/null +++ b/config/feature_flags/development/integration_zentao_issues.yml @@ -0,0 +1,8 @@ +--- +name: integration_zentao_issues +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338775 +milestone: '14.3' +type: development +group: group::integrations +default_enabled: false diff --git a/config/metrics/counts_28d/20210801073526_i_ecosystem_zentao_integration_list_issues_monthly.yml b/config/metrics/counts_28d/20210801073526_i_ecosystem_zentao_integration_list_issues_monthly.yml new file mode 100644 index 0000000000000000000000000000000000000000..d1167ea2d7784cc45ef628284e2653932fb5fa2d --- /dev/null +++ b/config/metrics/counts_28d/20210801073526_i_ecosystem_zentao_integration_list_issues_monthly.yml @@ -0,0 +1,24 @@ +--- +key_path: redis_hll_counters.ecosystem.i_ecosystem_zentao_integration_list_issues_monthly +name: i_ecosystem_zentao_integration_list_issues_monthly +description: 'Count of Zentao Issue List visits by month' +product_section: dev +product_stage: ecosystem +product_group: group::integrations +product_category: integrations +value_type: number +status: implemented +milestone: "14.3" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178 +time_frame: 28d +data_source: redis_hll +data_category: Optional +instrumentation_class: RedisHLLMetric +options: + events: + - i_ecosystem_zentao_integration_list_issues +distribution: +- ee +tier: +- premium +- ultimate \ No newline at end of file diff --git a/config/metrics/counts_7d/20210801073522_i_ecosystem_zentao_integration_list_issues_weekly.yml b/config/metrics/counts_7d/20210801073522_i_ecosystem_zentao_integration_list_issues_weekly.yml new file mode 100644 index 0000000000000000000000000000000000000000..cf17826c5b63becbfb631b2cb7cbaff14eefe84a --- /dev/null +++ b/config/metrics/counts_7d/20210801073522_i_ecosystem_zentao_integration_list_issues_weekly.yml @@ -0,0 +1,24 @@ +--- +key_path: redis_hll_counters.ecosystem.i_ecosystem_zentao_integration_list_issues_weekly +name: i_ecosystem_zentao_integration_list_issues_weekly +description: 'Count of Zentao Issue List visits by week' +product_section: dev +product_stage: ecosystem +product_group: group::integrations +product_category: integrations +value_type: number +status: implemented +milestone: "14.3" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178 +time_frame: 7d +data_source: redis_hll +data_category: Optional +instrumentation_class: RedisHLLMetric +options: + events: + - i_ecosystem_zentao_integration_list_issues +distribution: +- ee +tier: +- premium +- ultimate diff --git a/config/metrics/counts_all/20210730011801_projects_zentao_active.yml b/config/metrics/counts_all/20210730011801_projects_zentao_active.yml new file mode 100644 index 0000000000000000000000000000000000000000..59ad8fbcbe70996fa2212f025b71e1192e05e158 --- /dev/null +++ b/config/metrics/counts_all/20210730011801_projects_zentao_active.yml @@ -0,0 +1,22 @@ +--- +key_path: counts.projects_zentao_active +name: count_all_projects_zentao_active +description: Count of projects with active integrations for Zentao +product_section: dev +product_stage: ecosystem +product_group: group::integrations +product_category: integrations +value_type: number +status: implemented +milestone: "14.3" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178 +time_frame: all +data_source: database +data_category: Operational +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_all/20210730011802_groups_zentao_active.yml b/config/metrics/counts_all/20210730011802_groups_zentao_active.yml new file mode 100644 index 0000000000000000000000000000000000000000..405f8099a34cd2b0e205917e84e7f042d9af15a2 --- /dev/null +++ b/config/metrics/counts_all/20210730011802_groups_zentao_active.yml @@ -0,0 +1,22 @@ +--- +key_path: counts.groups_zentao_active +name: count_all_groups_zentao_active +description: Count of projects with active integrations for Zentao +product_section: dev +product_stage: ecosystem +product_group: group::integrations +product_category: integrations +value_type: number +status: implemented +milestone: "14.3" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178 +time_frame: all +data_source: database +data_category: Operational +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_all/20210730011803_templates_zentao_active.yml b/config/metrics/counts_all/20210730011803_templates_zentao_active.yml new file mode 100644 index 0000000000000000000000000000000000000000..917788625af05e1eb510aa0e8bcdb7873d71a3f5 --- /dev/null +++ b/config/metrics/counts_all/20210730011803_templates_zentao_active.yml @@ -0,0 +1,22 @@ +--- +key_path: counts.templates_zentao_active +name: count_all_templates_zentao_active +description: Count of projects with active integrations for Zentao +product_section: dev +product_stage: ecosystem +product_group: group::integrations +product_category: integrations +value_type: number +status: implemented +milestone: "14.3" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178 +time_frame: all +data_source: database +data_category: Operational +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_all/20210730011804_instances_zentao_active.yml b/config/metrics/counts_all/20210730011804_instances_zentao_active.yml new file mode 100644 index 0000000000000000000000000000000000000000..b71df3f09d76a57fa0adcb70fd9d69251b00606f --- /dev/null +++ b/config/metrics/counts_all/20210730011804_instances_zentao_active.yml @@ -0,0 +1,22 @@ +--- +key_path: counts.instances_zentao_active +name: count_all_instances_zentao_active +description: Count of projects with active integrations for Zentao +product_section: dev +product_stage: ecosystem +product_group: group::integrations +product_category: integrations +value_type: number +status: implemented +milestone: "14.3" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178 +time_frame: all +data_source: database +data_category: Operational +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_all/20210730011805_projects_inheriting_zentao_active.yml b/config/metrics/counts_all/20210730011805_projects_inheriting_zentao_active.yml new file mode 100644 index 0000000000000000000000000000000000000000..fb60b2b43dafe2588ed67aa213dfee7972127074 --- /dev/null +++ b/config/metrics/counts_all/20210730011805_projects_inheriting_zentao_active.yml @@ -0,0 +1,22 @@ +--- +key_path: counts.projects_inheriting_zentao_active +name: count_all_projects_inheriting_zentao_active +description: Count of projects with active integrations for Zentao +product_section: dev +product_stage: ecosystem +product_group: group::integrations +product_category: integrations +value_type: number +status: implemented +milestone: "14.3" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178 +time_frame: all +data_source: database +data_category: Operational +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/metrics/counts_all/20210730011806_groups_inheriting_zentao_active.yml b/config/metrics/counts_all/20210730011806_groups_inheriting_zentao_active.yml new file mode 100644 index 0000000000000000000000000000000000000000..44a459ea6c4ce55ca94d0231b56a5bad04c39a02 --- /dev/null +++ b/config/metrics/counts_all/20210730011806_groups_inheriting_zentao_active.yml @@ -0,0 +1,22 @@ +--- +key_path: counts.groups_inheriting_zentao_active +name: count_all_groups_inheriting_zentao_active +description: Count of projects with active integrations for Zentao +product_section: dev +product_stage: ecosystem +product_group: group::integrations +product_category: integrations +value_type: number +status: implemented +milestone: "14.3" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338178 +time_frame: all +data_source: database +data_category: Operational +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate diff --git a/config/routes/project.rb b/config/routes/project.rb index 8ba9c100f71755bf975214cb8c6ba0e4d5b90ad3..0c9d32267a1a8a05d151891f339d265e5258cab7 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -441,6 +441,16 @@ end end end + + namespace :integrations do + namespace :zentao do + resources :issues, only: [:index, :show] do + member do + get :labels + end + end + end + end end # End of the /-/ scope. diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 1160d5fe83a252955ab3a4c76888017496fdc0d2..0f4cc164c979f068a3e7f5fd99a762cb689da4a7 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -15653,6 +15653,7 @@ State of a Sentry error. | `UNIFY_CIRCUIT_SERVICE` | UnifyCircuitService type. | | `WEBEX_TEAMS_SERVICE` | WebexTeamsService type. | | `YOUTRACK_SERVICE` | YoutrackService type. | +| `ZENTAO_SERVICE` | ZentaoService type. | ### `SharedRunnersSetting` diff --git a/doc/development/usage_ping/dictionary.md b/doc/development/usage_ping/dictionary.md index abd8da373ecd4561ffd905b4095e8836ab01988d..ebe7028b340fa501b1368d44d8deca2a673c370a 100644 --- a/doc/development/usage_ping/dictionary.md +++ b/doc/development/usage_ping/dictionary.md @@ -2470,6 +2470,20 @@ Status: `data_available` Tiers: `free`, `premium`, `ultimate` +### `counts.groups_zentao_active` + +Count of projects with active integrations for Zentao + +[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/counts_all/20210730011802_groups_zentao_active.yml) + +Group: `group::ecosystem` + +Data Category: `Operational` + +Status: `implemented` + +Tiers: `free`, `premium`, `ultimate` + ### `counts.in_product_marketing_email_admin_verify_0_cta_clicked` Total clicks on the admin_verify track's first email @@ -3604,6 +3618,20 @@ Status: `data_available` Tiers: `free`, `premium`, `ultimate` +### `counts.instances_zentao_active` + +Count of projects with active integrations for Zentao + +[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/counts_all/20210730011804_instances_zentao_active.yml) + +Group: `group::ecosystem` + +Data Category: `Operational` + +Status: `implemented` + +Tiers: `free`, `premium`, `ultimate` + ### `counts.issues` Count of Issues created @@ -5956,6 +5984,20 @@ Status: `data_available` Tiers: `free`, `premium`, `ultimate` +### `counts.projects_inheriting_zentao_active` + +Count of projects with active integrations for Zentao + +[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/counts_all/20210730011805_projects_inheriting_zentao_active.yml) + +Group: `group::ecosystem` + +Data Category: `Operational` + +Status: `implemented` + +Tiers: `free`, `premium`, `ultimate` + ### `counts.projects_irker_active` Count of projects with active integrations for Irker @@ -6740,6 +6782,20 @@ Status: `data_available` Tiers: `free`, `premium`, `ultimate` +### `counts.projects_zentao_active` + +Count of projects with active integrations for Zentao + +[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/counts_all/20210730011801_projects_zentao_active.yml) + +Group: `group::ecosystem` + +Data Category: `Operational` + +Status: `implemented` + +Tiers: `free`, `premium`, `ultimate` + ### `counts.protected_branches` Count of total protected branches @@ -7650,6 +7706,20 @@ Status: `data_available` Tiers: `free`, `premium`, `ultimate` +### `counts.templates_zentao_active` + +Count of projects with active integrations for Zentao + +[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/counts_all/20210730011803_templates_zentao_active.yml) + +Group: `group::ecosystem` + +Data Category: `Operational` + +Status: `implemented` + +Tiers: `free`, `premium`, `ultimate` + ### `counts.terraform_reports` Count of Terraform MR reports generated @@ -12844,6 +12914,34 @@ Status: `data_available` Tiers: `free`, `premium`, `ultimate` +### `redis_hll_counters.ecosystem.i_ecosystem_zentao_integration_list_issues_monthly` + +Count of Zentao Issue List visits by month + +[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/counts_28d/20210801073526_i_ecosystem_zentao_integration_list_issues_monthly.yml) + +Group: `group::ecosystem` + +Data Category: `Optional` + +Status: `implemented` + +Tiers: `premium`, `ultimate` + +### `redis_hll_counters.ecosystem.i_ecosystem_zentao_integration_list_issues_weekly` + +Count of Zentao Issue List visits by week + +[YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/metrics/counts_7d/20210801073522_i_ecosystem_zentao_integration_list_issues_weekly.yml) + +Group: `group::ecosystem` + +Data Category: `Optional` + +Status: `implemented` + +Tiers: `premium`, `ultimate` + ### `redis_hll_counters.epic_boards_usage.epic_boards_usage_total_unique_counts_monthly` Missing description diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb index 54e9224c8eff4bfa054ebf5f640b9cb7eb30f91b..bbc4fa565c75aaa3460e167191d03898d99301af 100644 --- a/ee/app/models/ee/project.rb +++ b/ee/app/models/ee/project.rb @@ -387,6 +387,10 @@ def jira_issues_integration_available? feature_available?(:jira_issues_integration) end + def zentao_issues_integration_available? + feature_available?(:zentao_issues_integration) + end + def multiple_approval_rules_available? feature_available?(:multiple_approval_rules) end diff --git a/ee/app/models/license.rb b/ee/app/models/license.rb index de9bd88972b111165bbd529a993524f2ea2dc8b1..4744ee0fbbbd5f63ad697a2c3cbc42b7edc6fe96 100644 --- a/ee/app/models/license.rb +++ b/ee/app/models/license.rb @@ -137,6 +137,7 @@ class License < ApplicationRecord oncall_schedules escalation_policies export_user_permissions + zentao_issues_integration ] EEP_FEATURES.freeze diff --git a/ee/lib/ee/sidebars/projects/menus/zentao_menu.rb b/ee/lib/ee/sidebars/projects/menus/zentao_menu.rb new file mode 100644 index 0000000000000000000000000000000000000000..b11ff5a3e7d036a2fe3e5191e539d286bd97fe67 --- /dev/null +++ b/ee/lib/ee/sidebars/projects/menus/zentao_menu.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module EE + module Sidebars + module Projects + module Menus + module ZentaoMenu + extend ::Gitlab::Utils::Override + + override :link + def link + if full_feature? + project_integrations_zentao_issues_path(context.project) + else + super + end + end + + override :add_items + def add_items + add_item(issue_list_menu_item) if full_feature? + super + end + + private + + def full_feature? + context.project.zentao_issues_integration_available? + end + + def issue_list_menu_item + ::Sidebars::MenuItem.new( + title: s_('ZentaoIntegration|Issue List'), + link: project_integrations_zentao_issues_path(context.project), + active_routes: { controller: 'projects/integrations/zentao/issues' }, + item_id: :issue_list + ) + end + end + end + end + end +end diff --git a/ee/spec/lib/ee/sidebars/projects/menus/zentao_menu_spec.rb b/ee/spec/lib/ee/sidebars/projects/menus/zentao_menu_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e84358a2ea3d575b2b2cd5445eae696c4d6c398d --- /dev/null +++ b/ee/spec/lib/ee/sidebars/projects/menus/zentao_menu_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('spec/lib/sidebars/projects/menus/zentao_menu_shared_examples') + +RSpec.describe Sidebars::Projects::Menus::ZentaoMenu do + let(:project) { create(:project, has_external_issue_tracker: true) } + let(:user) { project.owner } + let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) } + let(:zentao_integration) { create(:zentao_integration, project: project) } + + subject { described_class.new(context) } + + describe 'with STARTER_PLAN' do + before do + stub_licensed_features(zentao_issues_integration: false) + end + + it_behaves_like 'zentao menu with CE version' + end + + describe 'with PREMIUM_PLAN or ULTIMATE_PLAN' do + before do + stub_licensed_features(zentao_issues_integration: true) + end + + context 'when issues integration is disabled' do + before do + zentao_integration.update!(active: false) + end + + it 'returns false' do + expect(subject.render?).to eq false + end + end + + context 'when issues integration is enabled' do + before do + zentao_integration.update!(active: true) + end + + it 'returns true' do + expect(subject.render?).to eq true + end + + it 'renders menu link' do + expect(subject.link).to include('/-/integrations/zentao/issues') + end + + it 'contains issue list and open zentao menu items' do + expect(subject.renderable_items.map(&:item_id)).to eq [:issue_list, :open_zentao] + end + end + end +end diff --git a/lib/api/helpers/integrations_helpers.rb b/lib/api/helpers/integrations_helpers.rb index 06539772568279c4257aa08f925b11776b8a1e68..73ed712dfd88eb99eacc70476da34d39d73b49ba 100644 --- a/lib/api/helpers/integrations_helpers.rb +++ b/lib/api/helpers/integrations_helpers.rb @@ -160,7 +160,7 @@ def self.chat_notification_events end def self.integrations - { + integrations = { 'asana' => [ { required: true, @@ -770,10 +770,41 @@ def self.integrations chat_notification_events ].flatten } + + zentao_integration = { + 'zentao' => [ + { + required: true, + name: :url, + type: String, + desc: 'The base URL to the Zentao instance web interface which is being linked to this GitLab project. E.g., https://www.zentao.net' + }, + { + required: false, + name: :api_url, + type: String, + desc: 'The base URL to the Zentao instance API. Web URL value will be used if not set. E.g., https://www.zentao.net' + }, + { + required: true, + name: :api_token, + type: String, + desc: 'The API token created from Zentao dashboard' + }, + { + required: true, + name: :zentao_product_xid, + type: String, + desc: 'The product ID of Zentao project' + } + ] + } + integrations.merge!(zentao_integration) if ::Integrations::Zentao.feature_flag_enabled? + integrations end def self.integration_classes - [ + integration_classes = [ ::Integrations::Asana, ::Integrations::Assembla, ::Integrations::Bamboo, @@ -807,6 +838,8 @@ def self.integration_classes ::Integrations::Teamcity, ::Integrations::Youtrack ] + integration_classes.push(::Integrations::Zentao) if ::Integrations::Zentao.feature_flag_enabled? + integration_classes end def self.development_integration_classes diff --git a/lib/gitlab/integrations/sti_type.rb b/lib/gitlab/integrations/sti_type.rb index 0fa9f435b5c68181eb32d126e1a53fa887622241..91797a7b99bcafd911aa68a3eed1361f063e07b7 100644 --- a/lib/gitlab/integrations/sti_type.rb +++ b/lib/gitlab/integrations/sti_type.rb @@ -7,7 +7,7 @@ class StiType < ActiveRecord::Type::String Asana Assembla Bamboo Bugzilla Buildkite Campfire Confluence CustomIssueTracker Datadog Discord DroneCi EmailsOnPush Ewm ExternalWiki Flowdock HangoutsChat Irker Jenkins Jira Mattermost MattermostSlashCommands MicrosoftTeams MockCi MockMonitoring Packagist PipelinesEmail Pivotaltracker - Prometheus Pushover Redmine Slack SlackSlashCommands Teamcity UnifyCircuit WebexTeams Youtrack + Prometheus Pushover Redmine Slack SlackSlashCommands Teamcity UnifyCircuit WebexTeams Youtrack Zentao )).freeze def self.namespaced_integrations diff --git a/lib/gitlab/usage_data_counters/known_events/ecosystem.yml b/lib/gitlab/usage_data_counters/known_events/ecosystem.yml index f594c6a1b7c3650d6bc004753f047e1cff96bc5b..7ad48defb3245379973e1c0a461f979a69261588 100644 --- a/lib/gitlab/usage_data_counters/known_events/ecosystem.yml +++ b/lib/gitlab/usage_data_counters/known_events/ecosystem.yml @@ -52,3 +52,7 @@ category: ecosystem redis_slot: ecosystem aggregation: weekly +- name: i_ecosystem_zentao_integration_list_issues + category: ecosystem + redis_slot: ecosystem + aggregation: weekly diff --git a/lib/gitlab/zentao/client.rb b/lib/gitlab/zentao/client.rb new file mode 100644 index 0000000000000000000000000000000000000000..d3bd96b02a3363b6f5a6f37819dbd8f746b5ec50 --- /dev/null +++ b/lib/gitlab/zentao/client.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Gitlab + module Zentao + class Client + Error = Class.new(StandardError) + ConfigError = Class.new(Error) + + attr_reader :integration + + def initialize(integration) + raise ConfigError, 'Please check your integration configuration.' if integration.nil? + + @integration = integration + end + + def ping + response = fetch_product(zentao_product_xid) + + active = response.fetch('deleted') == '0' rescue false + if active + { success: true, message: '' } + else + { success: false, message: 'Not Found' } + end + end + + def fetch_product(product_id) + get("products/#{product_id}") + end + + def fetch_issues(params = {}) + get("products/#{zentao_product_xid}/issues", + params.reverse_merge(page: 1, limit: 20)) + end + + def fetch_issue(issue_id) + get("issues/#{issue_id}") + end + + private + + def get(path, params = {}) + options = { headers: headers, query: params } + + response = Gitlab::HTTP.get(url(path), options) + return {} unless response.success? + + Gitlab::Json.parse(response.body) + rescue JSON::ParserError + {} + end + + def url(path) + host = integration.api_url.presence || integration.url + + URI.join(host, '/api.php/v1/', path) + end + + def headers + { + 'Content-Type': 'application/json', + 'Token': integration.api_token + } + end + + def zentao_product_xid + integration.zentao_product_xid + end + end + end +end diff --git a/lib/sidebars/projects/menus/zentao_menu.rb b/lib/sidebars/projects/menus/zentao_menu.rb new file mode 100644 index 0000000000000000000000000000000000000000..fe90eac3008b88f015212e311a5aa502d9aed571 --- /dev/null +++ b/lib/sidebars/projects/menus/zentao_menu.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Sidebars + module Projects + module Menus + class ZentaoMenu < ::Sidebars::Menu + override :configure_menu_items + def configure_menu_items + render?.tap do |render| + break unless render + + add_items + end + end + + override :link + def link + zentao_integration.url + end + + override :title + def title + s_('ZentaoIntegration|Zentao Issues') + end + + override :title_html_options + def title_html_options + { + id: 'js-onboarding-settings-link' + } + end + + override :image_path + def image_path + 'logos/zentao.svg' + end + + # Hardcode sizes so image doesn't flash before CSS loads https://gitlab.com/gitlab-org/gitlab/-/issues/321022 + override :image_html_options + def image_html_options + { + size: 16 + } + end + + override :render? + def render? + return false if zentao_integration.blank? + + zentao_integration.active? + end + + def add_items + add_item(open_zentao_menu_item) + end + + private + + def zentao_integration + @zentao_integration ||= context.project.zentao_integration + end + + def open_zentao_menu_item + ::Sidebars::MenuItem.new( + title: s_('ZentaoIntegration|Open Zentao'), + link: zentao_integration.url, + active_routes: {}, + item_id: :open_zentao, + sprite_icon: 'external-link', + container_html_options: { + target: '_blank', + rel: 'noopener noreferrer' + } + ) + end + end + end + end +end + +::Sidebars::Projects::Menus::ZentaoMenu.prepend_mod diff --git a/lib/sidebars/projects/panel.rb b/lib/sidebars/projects/panel.rb index d5311c0a0c143d20297b1c1a630a0af8d025d274..374662162b5c857b19cee8f5fc2d4432e1a2f1cb 100644 --- a/lib/sidebars/projects/panel.rb +++ b/lib/sidebars/projects/panel.rb @@ -23,6 +23,7 @@ def add_menus add_menu(Sidebars::Projects::Menus::RepositoryMenu.new(context)) add_menu(Sidebars::Projects::Menus::IssuesMenu.new(context)) add_menu(Sidebars::Projects::Menus::ExternalIssueTrackerMenu.new(context)) + add_menu(Sidebars::Projects::Menus::ZentaoMenu.new(context)) add_menu(Sidebars::Projects::Menus::MergeRequestsMenu.new(context)) add_menu(Sidebars::Projects::Menus::CiCdMenu.new(context)) add_menu(Sidebars::Projects::Menus::SecurityComplianceMenu.new(context)) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0a34adb8894839d5a36c6c60e8a3558647f80df9..0118147c2afcd073c36531f721b9c2ce23e0ff83 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3767,6 +3767,9 @@ msgstr "" msgid "An error occurred while reordering issues." msgstr "" +msgid "An error occurred while requesting data from the Zentao service." +msgstr "" + msgid "An error occurred while retrieving calendar activity" msgstr "" @@ -17930,6 +17933,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 "" @@ -17972,6 +17978,9 @@ msgstr "" msgid "Integrations|Issues created in Jira are shown here once you have created the issues in project setup in Jira." msgstr "" +msgid "Integrations|Issues created in Zentao are shown here once you have created the issues in project setup in Zentao." +msgstr "" + msgid "Integrations|Keep your PHP dependencies updated on Packagist." msgstr "" @@ -18023,6 +18032,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 "" @@ -38713,6 +38725,66 @@ msgstr "" msgid "Your username is %{username}." msgstr "" +msgid "Zentao Issues" +msgstr "" + +msgid "Zentao user" +msgstr "" + +msgid "ZentaoIntegration|Base URL of the Zentao instance." +msgstr "" + +msgid "ZentaoIntegration|Enter API token" +msgstr "" + +msgid "ZentaoIntegration|Failed to load Zentao issue statuses. View the issue in Zentao, or reload the page." +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|If different from Web URL." +msgstr "" + +msgid "ZentaoIntegration|Issue List" +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|Open Zentao" +msgstr "" + +msgid "ZentaoIntegration|This issue is synchronized with Zentao" +msgstr "" + +msgid "ZentaoIntegration|Use Zentao to manage issues." +msgstr "" + +msgid "ZentaoIntegration|Zentao API URL" +msgstr "" + +msgid "ZentaoIntegration|Zentao API token" +msgstr "" + +msgid "ZentaoIntegration|Zentao Issues" +msgstr "" + +msgid "ZentaoIntegration|Zentao Product ID" +msgstr "" + +msgid "ZentaoIntegration|Zentao Web URL" +msgstr "" + msgid "Zoom meeting added" msgstr "" diff --git a/spec/factories/integration_data.rb b/spec/factories/integration_data.rb index a7406794437c389270049a7c927295075ac9adbe..4d0892556f8e12a621fd2adb6186807c45705a38 100644 --- a/spec/factories/integration_data.rb +++ b/spec/factories/integration_data.rb @@ -7,13 +7,21 @@ integration factory: :jira_integration end + factory :zentao_tracker_data, class: 'Integrations::ZentaoTrackerData' do + integration factory: :zentao_integration + url { 'https://jihudemo.zentao.net' } + api_url { '' } + api_token { 'ZENTAO_TOKEN' } + zentao_product_xid { '3' } + end + factory :issue_tracker_data, class: 'Integrations::IssueTrackerData' do integration end factory :open_project_tracker_data, class: 'Integrations::OpenProjectTrackerData' do integration factory: :open_project_service - url { 'http://openproject.example.com'} + url { 'http://openproject.example.com' } token { 'supersecret' } project_identifier_code { 'PRJ-1' } closed_status_id { '15' } diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index a5a17ca4058da17884420882ece08459b2ef1641..8f440ee4698a7d546fbd6cb14e305782fcb1aa7f 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -79,6 +79,32 @@ end end + factory :zentao_integration, class: 'Integrations::Zentao' do + project + active { true } + type { 'ZentaoService' } + + transient do + create_data { true } + url { 'https://jihudemo.zentao.net' } + api_url { '' } + api_token { 'ZENTAO_TOKEN' } + zentao_product_xid { '3' } + end + + after(:build) do |integration, evaluator| + if evaluator.create_data + integration.zentao_tracker_data = build(:zentao_tracker_data, + integration: integration, + url: evaluator.url, + api_url: evaluator.api_url, + api_token: evaluator.api_token, + zentao_product_xid: evaluator.zentao_product_xid + ) + end + end + end + factory :confluence_integration, class: 'Integrations::Confluence' do project active { true } 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..f1d17cf4b11e46a01535637eebb3a51868d07ef8 --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_list/components/zentao_issues_list_empty_state_spec.js @@ -0,0 +1,203 @@ +import { GlEmptyState, GlSprintf, GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import ZentaoIssuesListEmptyState from '~/integrations/zentao/issues_list/components/zentao_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 = + 'Issues created in Zentao are shown here once you have created the issues in project setup 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: + 'Issues created in Zentao are shown here once you have created the issues in project setup 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..429ea58374ed99d7807b2d3483ebf8c1347c6a1f --- /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 ZentaoIssuesListRoot from '~/integrations/zentao/issues_list/components/zentao_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: { + 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(ZentaoIssuesListRoot, { + 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: { + zentaoIssues: 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..7a56163c6ab73a16d1390e4576da75f2188a029d --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_list/mock_data.js @@ -0,0 +1,93 @@ +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', +}; + +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..1a8829cc135ce67165ff85e1205c68af52af953c --- /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..4d2ec1531fb359078f52d24edd180d4b24ed9c55 --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/components/sidebar/assignee_spec.js @@ -0,0 +1,121 @@ +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/zentao/issues_show/components/sidebar/assignee.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue'; + +import { mockZentaoIssue } 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, + }, + }), + ); + }; + + 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..ceaee1791b952ecb81306ea5e0a0724c73650692 --- /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/zentao/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..7407afe787e997b3bf9f1a15474d80b68783a823 --- /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/zentao/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..d32cfded353b4cd997a484a1d670ac7fd01a5e88 --- /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/zentao/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..78e35d46eacdef7ab9f01cc84e9fa69c6b7c7fca --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/components/sidebar/zentao_issues_sidebar_root_spec.js @@ -0,0 +1,63 @@ +import { shallowMount } from '@vue/test-utils'; +import Assignee from '~/integrations/zentao/issues_show/components/sidebar/assignee.vue'; +import IssueDueDate from '~/integrations/zentao/issues_show/components/sidebar/issue_due_date.vue'; +import IssueField from '~/integrations/zentao/issues_show/components/sidebar/issue_field.vue'; +import Sidebar from '~/integrations/zentao/issues_show/components/sidebar/zentao_issues_sidebar_root.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 } 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 }, + }); + }; + + 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..8a804cfb70b255811f9e56ed6258a7f6cc11f509 --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/components/zentao_issues_show_root_spec.js @@ -0,0 +1,181 @@ +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/zentao/issues_show/api'; +import ZentaoIssueSidebar from '~/integrations/zentao/issues_show/components/sidebar/zentao_issues_sidebar_root.vue'; +import ZentaoIssuesShow from '~/integrations/zentao/issues_show/components/zentao_issues_show_root.vue'; +import { issueStates } from '~/integrations/zentao/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 { mockZentaoIssue } 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, + }, + }); + }; + + 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( + '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..8c7f9147c98ac3b8f034e66c38e21f8ed83b55ae --- /dev/null +++ b/spec/frontend/integrations/zentao/issues_show/mock_data.js @@ -0,0 +1,45 @@ +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' }]; diff --git a/spec/graphql/types/projects/services_enum_spec.rb b/spec/graphql/types/projects/services_enum_spec.rb index 00427e1d5800d7675a06f0acfc44cc5765dadac9..1d371b812bd08daa4c7efc7dbb7c1e4dcd773b08 100644 --- a/spec/graphql/types/projects/services_enum_spec.rb +++ b/spec/graphql/types/projects/services_enum_spec.rb @@ -4,6 +4,7 @@ RSpec.describe GitlabSchema.types['ServiceType'] do it 'exposes all the existing project services' do + stub_feature_flags(integration_zentao_issues: false) expect(described_class.values.keys).to match_array(available_services_enum) end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 36eb1c04c817a20a5915cc8dabed4c3079109b68..effc70736961a166523c27b3373dfcdb29a36657 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -317,6 +317,7 @@ integrations: - project - service_hook - jira_tracker_data +- zentao_tracker_data - issue_tracker_data - open_project_tracker_data hooks: @@ -394,6 +395,7 @@ project: - teamcity_integration - pushover_integration - jira_integration +- zentao_integration - redmine_integration - youtrack_integration - custom_issue_tracker_integration diff --git a/spec/lib/gitlab/zentao/client_spec.rb b/spec/lib/gitlab/zentao/client_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b304490c9b9a59304907883f759db3d64b629496 --- /dev/null +++ b/spec/lib/gitlab/zentao/client_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Zentao::Client do + let(:zentao_integration) { create(:zentao_integration) } + let(:mock_get_products_url) { subject.send(:url, "products/#{zentao_integration.zentao_product_xid}") } + + subject { described_class.new(zentao_integration) } + + describe '#new' do + context 'if integration is nil' do + it 'raises ConfigError' do + expect { described_class.new(nil) }.to raise_error(described_class::ConfigError) + end + end + + context 'if integration is current' do + it 'generates a client object' do + expect { subject }.not_to raise_error + end + end + end + + describe '#fetch_product' do + let(:mock_headers) do + { + headers: { + 'Content-Type' => 'application/json', + 'Token' => zentao_integration.api_token + } + } + end + + context 'with valid product' do + let(:mock_response) { { 'id' => zentao_integration.zentao_product_xid, 'deleted' => '0' } } + + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: mock_response.to_json) + end + + it 'fetches the product' do + expect(subject.fetch_product(zentao_integration.zentao_product_xid)).to eq mock_response + end + end + + context 'with invalid product' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 404, body: {}.to_json) + end + + it 'fetches the empty product' do + expect(subject.fetch_product(zentao_integration.zentao_product_xid)).to eq({}) + end + end + + context 'with invalid response' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: '[invalid json}') + end + + it 'fetches the empty product' do + expect(subject.fetch_product(zentao_integration.zentao_product_xid)).to eq({}) + end + end + end + + describe '#ping' do + let(:mock_headers) do + { + headers: { + 'Content-Type' => 'application/json', + 'Token' => zentao_integration.api_token + } + } + end + + context 'with valid config' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: { 'deleted' => '0' }.to_json) + end + + it 'responds with success' do + expect(subject.ping[:success]).to eq true + end + end + + context 'with invalid config' do + context 'with deleted resource' do + before do + WebMock.stub_request(:get, mock_get_products_url) + .with(mock_headers).to_return(status: 200, body: { 'deleted' => '1' }.to_json) + end + + it 'responds with unsuccess' do + expect(subject.ping[:success]).to eq false + end + end + end + end +end diff --git a/spec/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb b/spec/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..ba091fe58c66e67ca9ae1e1821e549ac8563ae16 --- /dev/null +++ b/spec/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'zentao menu with CE version' do + let(:project) { create(:project, has_external_issue_tracker: true) } + let(:user) { project.owner } + let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) } + let(:zentao_integration) { create(:zentao_integration, project: project) } + + subject { described_class.new(context) } + + describe '#render?' do + context 'when issues integration is disabled' do + before do + zentao_integration.update!(active: false) + end + + it 'returns false' do + expect(subject.render?).to eq false + end + end + + context 'when issues integration is enabled' do + before do + zentao_integration.update!(active: true) + end + + it 'returns true' do + expect(subject.render?).to eq true + end + + it 'renders menu link' do + expect(subject.link).to eq zentao_integration.url + end + + it 'contains only open zentao item' do + expect(subject.renderable_items.map(&:item_id)).to eq [:open_zentao] + end + end + end +end diff --git a/spec/lib/sidebars/projects/menus/zentao_menu_spec.rb b/spec/lib/sidebars/projects/menus/zentao_menu_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8a45c3465bd0b279c9641f745d35dfd345b7a1dc --- /dev/null +++ b/spec/lib/sidebars/projects/menus/zentao_menu_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('spec/lib/sidebars/projects/menus/zentao_menu_shared_examples') + +RSpec.describe Sidebars::Projects::Menus::ZentaoMenu do + it_behaves_like 'zentao menu with CE version' +end diff --git a/spec/models/integrations/zentao_spec.rb b/spec/models/integrations/zentao_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5f7b17fe3a60bd6a6d93bf6af29cf018290b312e --- /dev/null +++ b/spec/models/integrations/zentao_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::Zentao do + let_it_be(:project) { create(:project, :repository) } + + let(:url) { 'https://jihudemo.zentao.net' } + let(:api_url) { 'https://jihudemo.zentao.net' } + let(:api_token) { 'ZENTAO_TOKEN' } + let(:zentao_product_xid) { '3' } + let(:zentao_integration) { create(:zentao_integration) } + + describe '#create' do + let(:params) do + { + project: project, + url: url, + api_url: api_url, + api_token: api_token, + zentao_product_xid: zentao_product_xid + } + end + + it 'stores data in data_fields correctly' do + tracker_data = described_class.create!(params).zentao_tracker_data + + expect(tracker_data.url).to eq(url) + expect(tracker_data.api_url).to eq(api_url) + expect(tracker_data.api_token).to eq(api_token) + expect(tracker_data.zentao_product_xid).to eq(zentao_product_xid) + end + end + + describe '#fields' do + it 'returns custom fields' do + expect(zentao_integration.fields.pluck(:name)).to eq(%w[url api_url api_token zentao_product_xid]) + end + end + + describe '#test' do + let(:test_response) { { success: true, message: '' } } + + before do + allow_next_instance_of(Gitlab::Zentao::Client) do |client| + allow(client).to receive(:ping).and_return(test_response) + end + end + + it 'gets response from Gitlab::Zentao::Client#ping' do + expect(zentao_integration.test).to eq(test_response) + end + end +end diff --git a/spec/models/integrations/zentao_tracker_data_spec.rb b/spec/models/integrations/zentao_tracker_data_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b078c57830b094048c4c1e70d34a3d19489fe6db --- /dev/null +++ b/spec/models/integrations/zentao_tracker_data_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::ZentaoTrackerData do + describe 'factory available' do + let(:zentao_tracker_data) { create(:zentao_tracker_data) } + + it { expect(zentao_tracker_data.valid?).to eq true } + end + + describe 'associations' do + it { is_expected.to belong_to(:integration) } + end + + describe 'encrypted attributes' do + subject { described_class.encrypted_attributes.keys } + + it { is_expected.to contain_exactly(:url, :api_url, :zentao_product_xid, :api_token) } + end +end diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb index b1a9aade043e8baecc73f3217a7bb469083685c8..37f7a826de679527dce5f76ab57fc16b3d5c17c2 100644 --- a/spec/support/helpers/usage_data_helpers.rb +++ b/spec/support/helpers/usage_data_helpers.rb @@ -91,6 +91,7 @@ module UsageDataHelpers projects_jira_cloud_active projects_jira_dvcs_cloud_active projects_jira_dvcs_server_active + projects_zentao_active projects_slack_active projects_slack_slash_commands_active projects_custom_issue_tracker_active