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 @@
+
+
+
+
+
+
+
+
+
+ {{ s__('Integrations|Create new issue in Zentao') }}
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ s__('Integrations|Create new issue in Zentao') }}
+
+
+
+
+
+ {{ issuable.id }}
+
+
+
+
+
+ {{ author.name }}
+
+
+
+
+
+ {{ issuable.status }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ authorName }}
+
+
+
+ @{{ authorUsername }}
+
+
+ ยท
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
{{ __('None') }}
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
{{ $options.i18n.dueDateTitle }}
+
+ {{ formattedDueDate }}
+ {{ $options.i18n.none }}
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ valueWithFallback }}
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ {{ emptyText }}
+
+ {{ item.title }}
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ {{ __('None') }}
+
+
+
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 @@
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+ {{ content }}
+
+
+
+
+
+ {{ statusBadgeText }}
+
+
+
+
+
+
+
+
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`] = `
+
+`;
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