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