Commit 06e25a0f authored by Jeremy Wu's avatar Jeremy Wu Committed by Brandon Labuschagne

Feature ZenTao integration: Integrate ZenTao Detail

parent d5166223
......@@ -4,7 +4,7 @@ import { __ } from '~/locale';
import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue';
export default {
name: 'JiraIssuesSidebarAssignee',
name: 'ExternalIssuesSidebarAssignee',
components: {
GlAvatarLabeled,
GlAvatarLink,
......
......@@ -11,7 +11,7 @@ import Note from 'ee/external_issues_show/components/note.vue';
import { fetchIssue, fetchIssueStatuses, updateIssue } from 'ee/integrations/jira/issues_show/api';
import JiraIssueSidebar from 'ee/integrations/jira/issues_show/components/sidebar/jira_issues_sidebar_root.vue';
import { issueStates, issueStateLabels } from 'ee/integrations/jira/issues_show/constants';
import { issueStates, issueStateLabels } from 'ee/external_issues_show/constants';
import createFlash from '~/flash';
import IssuableShow from '~/issuable_show/components/issuable_show_root.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
......
......@@ -2,7 +2,8 @@
import Assignee from 'ee/external_issues_show/components/sidebar/assignee.vue';
import IssueDueDate from 'ee/external_issues_show/components/sidebar/issue_due_date.vue';
import IssueField from 'ee/external_issues_show/components/sidebar/issue_field.vue';
import { labelsFilterParam } from 'ee/integrations/jira/issues_show/constants';
import { labelsFilterParam } from 'ee/external_issues_show/constants';
import { __, s__ } from '~/locale';
import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
......
import { __ } from '~/locale';
export const issueStates = {
OPENED: 'opened',
CLOSED: 'closed',
};
export const issueStateLabels = {
[issueStates.OPENED]: __('Open'),
[issueStates.CLOSED]: __('Closed'),
};
export const labelsFilterParam = 'labels';
import axios from '~/lib/utils/axios_utils';
export const fetchIssue = (issuePath) => {
return axios.get(issuePath).then(({ data }) => {
return data;
});
};
<script>
import Assignee from 'ee/external_issues_show/components/sidebar/assignee.vue';
import IssueDueDate from 'ee/external_issues_show/components/sidebar/issue_due_date.vue';
import IssueField from 'ee/external_issues_show/components/sidebar/issue_field.vue';
import { __ } from '~/locale';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
export default {
name: 'ZentaoIssuesSidebar',
components: {
Assignee,
IssueDueDate,
IssueField,
LabelsSelect,
},
props: {
issue: {
type: Object,
required: true,
},
},
computed: {
assignee() {
// Zentao issues have at most 1 assignee
return (this.issue.assignees || [])[0];
},
reference() {
return this.issue.references?.relative;
},
},
i18n: {
statusTitle: __('Status'),
referenceName: __('Reference'),
},
};
</script>
<template>
<div>
<assignee class="block" :assignee="assignee" />
<issue-due-date :due-date="issue.dueDate" />
<issue-field icon="progress" :title="$options.i18n.statusTitle" :value="issue.status" />
<labels-select
:allow-scoped-labels="true"
:selected-labels="issue.labels"
variant="sidebar"
class="block labels"
>
{{ __('None') }}
</labels-select>
</div>
</template>
<script>
import {
GlAlert,
GlSprintf,
GlLink,
GlLoadingIcon,
GlBadge,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import Note from 'ee/external_issues_show/components/note.vue';
import { fetchIssue } from 'ee/integrations/zentao/issues_show/api';
import ZentaoIssueSidebar from 'ee/integrations/zentao/issues_show/components/sidebar/zentao_issues_sidebar_root.vue';
import { issueStates, issueStateLabels } from 'ee/external_issues_show/constants';
import IssuableShow from '~/issuable_show/components/issuable_show_root.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
export default {
name: 'ZenTaoIssuesShow',
components: {
GlAlert,
GlSprintf,
GlLink,
GlBadge,
GlLoadingIcon,
IssuableShow,
ZentaoIssueSidebar,
Note,
},
directives: {
GlTooltip,
},
inject: {
issuesShowPath: {
default: '',
},
},
data() {
return {
isLoading: true,
errorMessage: null,
issue: {},
};
},
computed: {
isIssueOpen() {
return this.issue.state === issueStates.OPENED;
},
statusBadgeClass() {
return this.isIssueOpen ? 'status-box-open' : 'status-box-issue-closed';
},
statusBadgeText() {
return issueStateLabels[this.issue?.state];
},
statusIcon() {
return this.isIssueOpen ? 'issue-open-m' : 'mobile-issue-close';
},
},
mounted() {
this.loadIssue();
},
methods: {
loadIssue() {
fetchIssue(this.issuesShowPath)
.then((issue) => {
if (!issue) {
throw new Error();
}
this.issue = convertObjectPropsToCamelCase(issue, { deep: true });
})
.catch(() => {
this.errorMessage = this.$options.i18n.defaultErrorMessage;
})
.finally(() => {
this.isLoading = false;
});
},
externalIssueCommentId(id) {
return `external_note_${id}`;
},
},
i18n: {
defaultErrorMessage: s__(
'ZenTaoIntegration|Failed to load ZenTao issue. View the issue in ZenTao, or reload the page.',
),
},
};
</script>
<template>
<div class="gl-mt-5">
<gl-loading-icon v-if="isLoading" size="lg" />
<gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false">
{{ errorMessage }}
</gl-alert>
<template v-else>
<gl-alert
variant="info"
:dismissible="false"
:title="s__('ZenTaoIntegration|This issue is synchronized with ZenTao')"
class="gl-mb-2"
>
<gl-sprintf
:message="
s__(
`ZenTaoIntegration|Not all data may be displayed here. To view more details or make changes to this issue, go to %{linkStart}ZenTao%{linkEnd}.`,
)
"
>
<template #link="{ content }">
<gl-link :href="issue.webUrl" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
<issuable-show
:issuable="issue"
:enable-edit="false"
:status-badge-class="statusBadgeClass"
:status-icon="statusIcon"
>
<template v-if="statusBadgeText" #status-badge>{{ statusBadgeText }}</template>
<template #right-sidebar-items>
<zentao-issue-sidebar :issue="issue" />
</template>
<template #discussion>
<note
v-for="comment in issue.comments"
:id="externalIssueCommentId(comment.id)"
:key="comment.id"
:author-avatar-url="comment.author.avatarUrl"
:author-web-url="comment.author.webUrl"
:author-name="comment.author.name"
:author-username="comment.author.username"
:note-body-html="comment.bodyHtml"
:note-created-at="comment.createdAt"
>
<template #badges>
<gl-badge v-gl-tooltip="{ title: s__('ZenTaoIntegration|This is a ZenTao user.') }">
{{ s__('ZenTaoIntegration|ZenTao user') }}
</gl-badge>
</template>
</note>
</template>
</issuable-show>
</template>
</div>
</template>
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),
});
}
import initZentaoIssueShow from 'ee/integrations/zentao/issues_show/zentao_issues_show_bundle';
initZentaoIssueShow({ mountPointSelector: '.js-zentao-issues-show-app' });
......@@ -5,7 +5,7 @@ import MockAdapter from 'axios-mock-adapter';
import * as JiraIssuesShowApi from 'ee/integrations/jira/issues_show/api';
import JiraIssuesShow from 'ee/integrations/jira/issues_show/components/jira_issues_show_root.vue';
import JiraIssueSidebar from 'ee/integrations/jira/issues_show/components/sidebar/jira_issues_sidebar_root.vue';
import { issueStates } from 'ee/integrations/jira/issues_show/constants';
import { issueStates } from 'ee/external_issues_show/constants';
import waitForPromises from 'helpers/wait_for_promises';
import IssuableHeader from '~/issuable_show/components/issuable_header.vue';
import IssuableShow from '~/issuable_show/components/issuable_show_root.vue';
......
import { shallowMount } from '@vue/test-utils';
import Assignee from 'ee/external_issues_show/components/sidebar/assignee.vue';
import IssueDueDate from 'ee/external_issues_show/components/sidebar/issue_due_date.vue';
import IssueField from 'ee/external_issues_show/components/sidebar/issue_field.vue';
......
import { shallowMount } from '@vue/test-utils';
import Assignee from 'ee/external_issues_show/components/sidebar/assignee.vue';
import IssueDueDate from 'ee/external_issues_show/components/sidebar/issue_due_date.vue';
import IssueField from 'ee/external_issues_show/components/sidebar/issue_field.vue';
import Sidebar from 'ee/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 = () => {
wrapper = shallowMount(Sidebar, {
propsData: defaultProps,
});
};
beforeEach(() => {
createComponent();
});
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', () => {
expect(findLabelsSelect().props('selectedLabels')).toBe(mockZentaoIssue.labels);
});
it('renders Assignee block', () => {
const assignee = findAssignee();
expect(assignee.props('assignee')).toBe(mockZentaoIssue.assignees[0]);
});
it('renders IssueDueDate', () => {
const dueDate = findIssueDueDate();
expect(dueDate.props('dueDate')).toBe(mockZentaoIssue.dueDate);
});
it('renders IssueField', () => {
const field = findIssueField();
expect(field.props('icon')).toBe('progress');
expect(field.props('title')).toBe('Status');
expect(field.props('value')).toBe(mockZentaoIssue.status);
});
});
import { GlAlert, GlLoadingIcon, GlBadge } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { issueStates } from 'ee/external_issues_show/constants';
import ZentaoIssuesShow from 'ee/integrations/zentao/issues_show/components/zentao_issues_show_root.vue';
import waitForPromises from 'helpers/wait_for_promises';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import IssuableHeader from '~/issuable_show/components/issuable_header.vue';
import IssuableShow from '~/issuable_show/components/issuable_show_root.vue';
import IssuableDiscussion from '~/issuable_show/components/issuable_discussion.vue';
import Note from 'ee/external_issues_show/components/note.vue';
import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { mockZentaoIssue, mockZentaoIssueComment } 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 findIssuableShowStatusBadge = () =>
wrapper.findComponent(IssuableHeader).find('[data-testid="status"]');
const createComponent = () => {
wrapper = shallowMountExtended(ZentaoIssuesShow, {
directives: {
GlTooltip: createMockDirective(),
},
stubs: {
IssuableHeader,
IssuableShow,
IssuableSidebar,
IssuableDiscussion,
Note,
GlBadge,
},
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(httpStatusCodes.INTERNAL_SERVER_ERROR);
createComponent();
await waitForPromises();
const alert = findGlAlert();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(ZentaoIssuesShow.i18n.defaultErrorMessage);
expect(alert.props('variant')).toBe('danger');
expect(findIssuableShow().exists()).toBe(false);
});
});
it('renders IssuableShow', async () => {
mockAxios.onGet(mockZentaoIssuesShowPath).replyOnce(httpStatusCodes.OK, mockZentaoIssue);
createComponent();
await waitForPromises();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(findIssuableShow().exists()).toBe(true);
});
it('displays a tooltip', async () => {
mockAxios.onGet(mockZentaoIssuesShowPath).replyOnce(httpStatusCodes.OK, {
...mockZentaoIssue,
comments: [mockZentaoIssueComment],
});
createComponent();
await waitForPromises();
const issuableDiscussion = wrapper.findComponent(IssuableDiscussion).findComponent(GlBadge);
const tooltip = getBinding(issuableDiscussion.element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value).toEqual({ title: 'This is a ZenTao user.' });
});
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(httpStatusCodes.OK, { ...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);
});
});
});
export const mockZentaoIssue = {
title: 'FE-2 The second FE issue on Zentao',
description_html:
'<a href="https://zentao.reali.sh:8080/projects/FE/issues/FE-2">FE-2</a> 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: '<p>hi</p>',
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,
};
......@@ -39619,6 +39619,21 @@ msgstr[1] ""
msgid "Your username is %{username}."
msgstr ""
msgid "ZenTaoIntegration|Failed to load ZenTao issue. View the issue in ZenTao, or reload the page."
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 is a ZenTao user."
msgstr ""
msgid "ZenTaoIntegration|This issue is synchronized with ZenTao"
msgstr ""
msgid "ZenTaoIntegration|ZenTao user"
msgstr ""
msgid "ZentaoIntegration|Base URL of the Zentao instance."
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment