Commit 91783e1a authored by Kushal Pandya's avatar Kushal Pandya Committed by Natalia Tepluhina

Add Jira app feature flag and mount-point

- Adds `jira_issues_list` feature flag
- Adds mount-point for new app.
parent 8b043949
...@@ -116,7 +116,8 @@ export default { ...@@ -116,7 +116,8 @@ export default {
<div data-testid="issuable-title" class="issue-title title"> <div data-testid="issuable-title" class="issue-title title">
<span class="issue-title-text" dir="auto"> <span class="issue-title-text" dir="auto">
<gl-link :href="issuable.webUrl" v-bind="issuableTitleProps" <gl-link :href="issuable.webUrl" v-bind="issuableTitleProps"
>{{ issuable.title }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" >{{ issuable.title
}}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
/></gl-link> /></gl-link>
</span> </span>
</div> </div>
...@@ -134,7 +135,9 @@ export default { ...@@ -134,7 +135,9 @@ export default {
>{{ createdAt }}</span >{{ createdAt }}</span
> >
{{ __('by') }} {{ __('by') }}
<slot v-if="hasSlotContents('author')" name="author"></slot>
<gl-link <gl-link
v-else
:data-user-id="authorId" :data-user-id="authorId"
:data-username="author.username" :data-username="author.username"
:data-name="author.name" :data-name="author.name"
......
<script> <script>
import { GlLoadingIcon, GlPagination } from '@gitlab/ui'; import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
...@@ -7,9 +7,11 @@ import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filte ...@@ -7,9 +7,11 @@ import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filte
import IssuableTabs from './issuable_tabs.vue'; import IssuableTabs from './issuable_tabs.vue';
import IssuableItem from './issuable_item.vue'; import IssuableItem from './issuable_item.vue';
import { DEFAULT_SKELETON_COUNT } from '../constants';
export default { export default {
components: { components: {
GlLoadingIcon, GlSkeletonLoading,
IssuableTabs, IssuableTabs,
FilteredSearchBar, FilteredSearchBar,
IssuableItem, IssuableItem,
...@@ -88,7 +90,7 @@ export default { ...@@ -88,7 +90,7 @@ export default {
required: false, required: false,
default: 20, default: 20,
}, },
totalPages: { totalItems: {
type: Number, type: Number,
required: false, required: false,
default: 0, default: 0,
...@@ -114,6 +116,19 @@ export default { ...@@ -114,6 +116,19 @@ export default {
default: true, default: true,
}, },
}, },
computed: {
skeletonItemCount() {
const { totalItems, defaultPageSize, currentPage } = this;
const totalPages = Math.ceil(totalItems / defaultPageSize);
if (totalPages) {
return currentPage < totalPages
? defaultPageSize
: totalItems % defaultPageSize || defaultPageSize;
}
return DEFAULT_SKELETON_COUNT;
},
},
watch: { watch: {
urlParams: { urlParams: {
deep: true, deep: true,
...@@ -157,7 +172,11 @@ export default { ...@@ -157,7 +172,11 @@ export default {
@onSort="$emit('sort', $event)" @onSort="$emit('sort', $event)"
/> />
<div class="issuables-holder"> <div class="issuables-holder">
<gl-loading-icon v-if="issuablesLoading" size="md" class="gl-mt-5" /> <ul v-if="issuablesLoading" class="content-list">
<li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!">
<gl-skeleton-loading />
</li>
</ul>
<ul <ul
v-if="!issuablesLoading && issuables.length" v-if="!issuablesLoading && issuables.length"
class="content-list issuable-list issues-list" class="content-list issuable-list issues-list"
...@@ -172,6 +191,9 @@ export default { ...@@ -172,6 +191,9 @@ export default {
<template #reference> <template #reference>
<slot name="reference" :issuable="issuable"></slot> <slot name="reference" :issuable="issuable"></slot>
</template> </template>
<template #author>
<slot name="author" :author="issuable.author"></slot>
</template>
<template #status> <template #status>
<slot name="status" :issuable="issuable"></slot> <slot name="status" :issuable="issuable"></slot>
</template> </template>
...@@ -181,7 +203,7 @@ export default { ...@@ -181,7 +203,7 @@ export default {
<gl-pagination <gl-pagination
v-if="showPaginationControls" v-if="showPaginationControls"
:per-page="defaultPageSize" :per-page="defaultPageSize"
:total-items="totalPages" :total-items="totalItems"
:value="currentPage" :value="currentPage"
:prev-page="previousPage" :prev-page="previousPage"
:next-page="nextPage" :next-page="nextPage"
......
...@@ -47,3 +47,5 @@ export const AvailableSortOptions = [ ...@@ -47,3 +47,5 @@ export const AvailableSortOptions = [
]; ];
export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_PAGE_SIZE = 20;
export const DEFAULT_SKELETON_COUNT = 5;
import initIssuablesList from '~/issues_list';
document.addEventListener('DOMContentLoaded', () => {
initIssuablesList();
});
---
name: jira_issues_list
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45678
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/273726
type: development
group: group::ecosystem
default_enabled: false
...@@ -5,4 +5,5 @@ export default { ...@@ -5,4 +5,5 @@ export default {
epics: 'epics-recent-searches', epics: 'epics-recent-searches',
requirements: 'requirements-recent-searches', requirements: 'requirements-recent-searches',
test_cases: 'test-cases-recent-searches', test_cases: 'test-cases-recent-searches',
jira_issues: 'jira-issues-recent-searches',
}; };
<script>
import { GlEmptyState, GlButton, GlIcon, GlSprintf } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { IssuableStates } from '~/issuable_list/constants';
export default {
FilterStateEmptyMessage: {
[IssuableStates.Opened]: __('There are no open issues'),
[IssuableStates.Closed]: __('There are no closed issues'),
},
components: {
GlEmptyState,
GlButton,
GlIcon,
GlSprintf,
},
inject: ['emptyStatePath', 'issueCreateUrl'],
props: {
currentState: {
type: String,
required: true,
},
issuesCount: {
type: Object,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
hasIssues() {
return this.issuesCount[IssuableStates.Opened] + this.issuesCount[IssuableStates.Closed] > 0;
},
emptyStateTitle() {
if (this.hasFiltersApplied) {
return __('Sorry, your filter produced no results');
} else if (this.hasIssues) {
return this.$options.FilterStateEmptyMessage[this.currentState];
}
return s__(
'Integrations|Issues created in Jira are shown here once you have created the issues in project setup in Jira.',
);
},
emptyStateDescription() {
if (this.hasFiltersApplied) {
return __('To widen your search, change or remove filters above');
} else if (!this.hasIssues) {
return s__('Integrations|To keep this project going, create a new issue.');
}
return '';
},
},
};
</script>
<template>
<gl-empty-state :svg-path="emptyStatePath" :title="emptyStateTitle">
<template v-if="!hasIssues || hasFiltersApplied" #description>
<gl-sprintf :message="emptyStateDescription" />
</template>
<template v-if="!hasIssues" #actions>
<gl-button :href="issueCreateUrl" target="_blank" category="primary" variant="success"
>{{ s__('Integrations|Create new issue in Jira') }}<gl-icon name="external-link"
/></gl-button>
</template>
</gl-empty-state>
</template>
<script>
import { GlButton, GlIcon, GlLink, GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg';
import { __ } from '~/locale';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import JiraIssuesListEmptyState from './jira_issues_list_empty_state.vue';
import {
IssuableStates,
IssuableListTabs,
AvailableSortOptions,
DEFAULT_PAGE_SIZE,
} from '~/issuable_list/constants';
export default {
name: 'JiraIssuesList',
IssuableListTabs,
AvailableSortOptions,
defaultPageSize: DEFAULT_PAGE_SIZE,
components: {
GlButton,
GlIcon,
GlLink,
GlSprintf,
IssuableList,
JiraIssuesListEmptyState,
},
directives: {
SafeHtml,
},
inject: [
'initialState',
'initialSortBy',
'page',
'issuesFetchPath',
'projectFullPath',
'issueCreateUrl',
],
props: {
initialFilterParams: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
jiraLogo,
issues: [],
issuesListLoading: false,
issuesListLoadFailed: false,
totalIssues: 0,
currentState: this.initialState,
filterParams: this.initialFilterParams,
sortedBy: this.initialSortBy,
currentPage: this.page,
issuesCount: {
[IssuableStates.Opened]: 0,
[IssuableStates.Closed]: 0,
[IssuableStates.All]: 0,
},
};
},
computed: {
showPaginationControls() {
return Boolean(
!this.issuesListLoading &&
!this.issuesListLoadFailed &&
this.issues.length &&
this.totalIssues > 1,
);
},
hasFiltersApplied() {
return Boolean(this.filterParams.search);
},
urlParams() {
return {
state: this.currentState,
page: this.currentPage,
sort: this.sortedBy,
search: this.filterParams.search,
};
},
},
mounted() {
this.fetchIssues();
},
methods: {
fetchIssues() {
this.issuesListLoading = true;
this.issuesListLoadFailed = false;
return axios
.get(this.issuesFetchPath, {
params: {
with_labels_details: true,
page: this.currentPage,
per_page: this.$options.defaultPageSize,
state: this.currentState,
sort: this.sortedBy,
search: this.filterParams.search,
},
})
.then(res => {
const { headers, data } = res;
this.currentPage = parseInt(headers['x-page'], 10);
this.totalIssues = parseInt(headers['x-total'], 10);
this.issues = data.map((rawIssue, index) => {
const issue = convertObjectPropsToCamelCase(rawIssue, { deep: true });
return {
...issue,
// JIRA issues don't have ID so we extract
// an ID equivalent from references.relative
id: parseInt(rawIssue.references.relative.split('-').pop(), 10),
author: {
...issue.author,
id: index,
},
};
});
this.issuesCount[this.currentState] = this.issues.length;
})
.catch(error => {
this.issuesListLoadFailed = true;
createFlash({
message: __('An error occurred while loading issues'),
captureError: true,
error,
});
})
.finally(() => {
this.issuesListLoading = false;
});
},
getFilteredSearchValue() {
return [
{
type: 'filtered-search-term',
value: {
data: this.filterParams.search || '',
},
},
];
},
fetchIssuesBy(propsName, propValue) {
this[propsName] = propValue;
this.fetchIssues();
},
handleFilterIssues(filters = []) {
const filterParams = {};
const plainText = [];
filters.forEach(filter => {
if (filter.type === 'filtered-search-term' && filter.value.data) {
plainText.push(filter.value.data);
}
});
if (plainText.length) {
filterParams.search = plainText.join(' ');
}
this.filterParams = filterParams;
this.fetchIssues();
},
},
};
</script>
<template>
<issuable-list
:namespace="projectFullPath"
:tabs="$options.IssuableListTabs"
:current-tab="currentState"
:search-input-placeholder="s__('Integrations|Search Jira issues')"
:search-tokens="[]"
:sort-options="$options.AvailableSortOptions"
:initial-filter-value="getFilteredSearchValue()"
:initial-sort-by="sortedBy"
:issuables="issues"
:issuables-loading="issuesListLoading"
:show-pagination-controls="showPaginationControls"
:default-page-size="$options.defaultPageSize"
:total-items="totalIssues"
:current-page="currentPage"
:previous-page="currentPage - 1"
:next-page="currentPage + 1"
:url-params="urlParams"
:enable-label-permalinks="false"
recent-searches-storage-key="jira_issues"
@click-tab="fetchIssuesBy('currentState', $event)"
@page-change="fetchIssuesBy('currentPage', $event)"
@sort="fetchIssuesBy('sortedBy', $event)"
@filter="handleFilterIssues"
>
<template #nav-actions>
<gl-button :href="issueCreateUrl" target="_blank"
>{{ s__('Integrations|Create new issue in Jira') }}<gl-icon name="external-link"
/></gl-button>
</template>
<template #reference="{ issuable }">
<span v-safe-html="jiraLogo" class="svg-container jira-logo-container"></span>
<span>{{ issuable.references.relative }}</span>
</template>
<template #author="{ author }">
<gl-sprintf message="%{authorName} in Jira">
<template #authorName>
<gl-link class="author-link js-user-link" target="_blank" :href="author.webUrl"
>{{ author.name }}
</gl-link>
</template>
</gl-sprintf>
</template>
<template #status="{ issuable }">
{{ issuable.status }}
</template>
<template #empty-state>
<jira-issues-list-empty-state
:current-state="currentState"
:issues-count="issuesCount"
:has-filters-applied="hasFiltersApplied"
/>
</template>
</issuable-list>
</template>
import Vue from 'vue';
import { urlParamsToObject, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { IssuableStates } from '~/issuable_list/constants';
import JiraIssuesListApp from './components/jira_issues_list_root.vue';
export default function initJiraIssuesList({ 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(urlParamsToObject(window.location.search.substring(1)), {
dropKeys: ['scope', 'utf8', 'state', 'sort'], // These keys are unsupported/unnecessary
}),
);
return new Vue({
el: mountPointEl,
provide: {
...mountPointEl.dataset,
page: parseInt(page, 10),
initialState,
initialSortBy,
},
render: createElement =>
createElement(JiraIssuesListApp, {
props: {
initialFilterParams,
},
}),
});
}
import initJiraIssuesList from 'ee/integrations/jira/issues_list/jira_issues_list_bundle';
import initIssuablesList from '~/issues_list';
document.addEventListener('DOMContentLoaded', () => {
if (gon.features.jiraIssuesList) {
initJiraIssuesList({
mountPointSelector: '#js-jira-issues-list',
});
} else {
initIssuablesList();
}
});
...@@ -12,6 +12,7 @@ module Projects ...@@ -12,6 +12,7 @@ module Projects
before_action do before_action do
push_frontend_feature_flag(:jira_issues_integration, project, type: :licensed, default_enabled: true) push_frontend_feature_flag(:jira_issues_integration, project, type: :licensed, default_enabled: true)
push_frontend_feature_flag(:jira_issues_list, project, type: :development)
end end
rescue_from ::Projects::Integrations::Jira::IssuesFinder::IntegrationError, with: :render_integration_error rescue_from ::Projects::Integrations::Jira::IssuesFinder::IntegrationError, with: :render_integration_error
......
- page_title _('Jira Issues') - page_title _('Jira Issues')
- add_page_specific_style 'page_bundles/issues_list' - add_page_specific_style 'page_bundles/issues_list'
.top-area.gl-border-b-0.gl-mt-6 - if Feature.enabled?(:jira_issues_list, @project, type: :development)
#js-jira-issues-list{ data: { issues_fetch_path: project_integrations_jira_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.external_issue_tracker.new_issue_url,
empty_state_path: image_path('illustrations/issues.svg') } }
- else
.top-area.gl-border-b-0.gl-mt-6
= render 'shared/issuable/nav', type: :issues, display_count: false = render 'shared/issuable/nav', type: :issues, display_count: false
= render 'projects/integrations/jira/issues/nav_btns' = render 'projects/integrations/jira/issues/nav_btns'
.js-issuables-list{ data: { endpoint: project_integrations_jira_issues_path(@project, format: :json), .js-issuables-list{ data: { endpoint: project_integrations_jira_issues_path(@project, format: :json),
'can-bulk-edit': false, 'can-bulk-edit': false,
'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') }, 'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
'sort-key': @sort, 'sort-key': @sort,
......
...@@ -9,6 +9,7 @@ RSpec.describe 'Jira issues list' do ...@@ -9,6 +9,7 @@ RSpec.describe 'Jira issues list' do
before do before do
stub_licensed_features(jira_issues_integration: true) stub_licensed_features(jira_issues_integration: true)
stub_feature_flags(jira_issues_list: false)
project.add_user(user, :developer) project.add_user(user, :developer)
sign_in(user) sign_in(user)
end end
......
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState, GlSprintf, GlButton } from '@gitlab/ui';
import JiraIssuesListEmptyState from 'ee/integrations/jira/issues_list/components/jira_issues_list_empty_state.vue';
import { IssuableStates } from '~/issuable_list/constants';
import { mockProvide } from '../mock_data';
const createComponent = (props = {}) =>
shallowMount(JiraIssuesListEmptyState, {
provide: mockProvide,
propsData: {
currentState: 'opened',
issuesCount: {
[IssuableStates.Opened]: 0,
[IssuableStates.Closed]: 0,
[IssuableStates.All]: 0,
},
hasFiltersApplied: false,
...props,
},
stubs: { GlEmptyState },
});
describe('JiraIssuesListEmptyState', () => {
const titleDefault =
'Issues created in Jira are shown here once you have created the issues in project setup in Jira.';
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 Jira are shown here once you have created the issues in project setup in Jira.',
});
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 Jira" 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 Jira');
});
});
});
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import JiraIssuesListRoot from 'ee/integrations/jira/issues_list/components/jira_issues_list_root.vue';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableStates, IssuableListTabs, AvailableSortOptions } from '~/issuable_list/constants';
import { mockProvide, mockJiraIssues } 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,
}));
const createComponent = ({ provide = mockProvide, initialFilterParams = {} } = {}) =>
shallowMount(JiraIssuesListRoot, {
propsData: {
initialFilterParams,
},
provide,
});
describe('JiraIssuesListRoot', () => {
const resolvedValue = {
headers: {
'x-page': 1,
'x-total': 3,
},
data: mockJiraIssues,
};
let wrapper;
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('computed', () => {
describe('showPaginationControls', () => {
it.each`
issuesListLoading | issuesListLoadFailed | issues | totalIssues | returnValue
${true} | ${false} | ${[]} | ${0} | ${false}
${false} | ${true} | ${[]} | ${0} | ${false}
${false} | ${false} | ${mockJiraIssues} | ${mockJiraIssues.length} | ${true}
`(
'returns $returnValue when issuesListLoading is $issuesListLoading, issuesListLoadFailed is $issuesListLoadFailed, issues is $issues and totalIssues is $totalIssues',
({ issuesListLoading, issuesListLoadFailed, issues, totalIssues, returnValue }) => {
wrapper.setData({
issuesListLoading,
issuesListLoadFailed,
issues,
totalIssues,
});
expect(wrapper.vm.showPaginationControls).toBe(returnValue);
},
);
});
describe('urlParams', () => {
it('returns object containing `state`, `page`, `sort` and `search` properties', () => {
wrapper.setData({
currentState: 'closed',
currentPage: 2,
sortedBy: 'created_asc',
filterParams: {
search: 'foo',
},
});
expect(wrapper.vm.urlParams).toMatchObject({
state: 'closed',
page: 2,
sort: 'created_asc',
search: 'foo',
});
});
});
});
describe('methods', () => {
describe('fetchIssues', () => {
it('sets issuesListLoading to true and issuesListLoadFailed to false', () => {
wrapper.vm.fetchIssues();
expect(wrapper.vm.issuesListLoading).toBe(true);
expect(wrapper.vm.issuesListLoadFailed).toBe(false);
});
it('calls `axios.get` with `issuesFetchPath` and query params', () => {
jest.spyOn(axios, 'get').mockResolvedValue(resolvedValue);
wrapper.vm.fetchIssues();
expect(axios.get).toHaveBeenCalledWith(
mockProvide.issuesFetchPath,
expect.objectContaining({
params: {
with_labels_details: true,
page: wrapper.vm.currentPage,
per_page: wrapper.vm.$options.defaultPageSize,
state: wrapper.vm.currentState,
sort: wrapper.vm.sortedBy,
search: wrapper.vm.filterParams.search,
},
}),
);
});
it('sets `currentPage` and `totalIssues` from response headers and `issues` & `issuesCount` from response body when request is successful', async () => {
jest.spyOn(axios, 'get').mockResolvedValue(resolvedValue);
await wrapper.vm.fetchIssues();
const firstIssue = convertObjectPropsToCamelCase(mockJiraIssues[0], { deep: true });
expect(wrapper.vm.currentPage).toBe(resolvedValue.headers['x-page']);
expect(wrapper.vm.totalIssues).toBe(resolvedValue.headers['x-total']);
expect(wrapper.vm.issues[0]).toEqual({
...firstIssue,
id: 31596,
author: {
...firstIssue.author,
id: 0,
},
});
expect(wrapper.vm.issuesCount[IssuableStates.Opened]).toBe(3);
});
it('sets `issuesListLoadFailed` to true and calls `createFlash` when request fails', async () => {
jest.spyOn(axios, 'get').mockRejectedValue({});
await wrapper.vm.fetchIssues();
expect(wrapper.vm.issuesListLoadFailed).toBe(true);
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while loading issues',
captureError: true,
error: expect.any(Object),
});
});
it('sets `issuesListLoading` to false when request completes', async () => {
jest.spyOn(axios, 'get').mockRejectedValue({});
await wrapper.vm.fetchIssues();
expect(wrapper.vm.issuesListLoading).toBe(false);
});
});
describe('fetchIssuesBy', () => {
it('sets provided prop value for given prop name and calls `fetchIssues`', () => {
jest.spyOn(wrapper.vm, 'fetchIssues');
wrapper.vm.fetchIssuesBy('currentPage', 2);
expect(wrapper.vm.currentPage).toBe(2);
expect(wrapper.vm.fetchIssues).toHaveBeenCalled();
});
});
});
describe('template', () => {
const getIssuableList = () => wrapper.find(IssuableList);
it('renders issuable-list component', async () => {
wrapper.setData({
filterParams: {
search: 'foo',
},
});
await wrapper.vm.$nextTick();
expect(getIssuableList().exists()).toBe(true);
expect(getIssuableList().props()).toMatchObject({
namespace: mockProvide.projectFullPath,
tabs: IssuableListTabs,
currentTab: 'opened',
searchInputPlaceholder: 'Search Jira issues',
searchTokens: [],
sortOptions: AvailableSortOptions,
initialFilterValue: [
{
type: 'filtered-search-term',
value: {
data: 'foo',
},
},
],
initialSortBy: 'created_desc',
issuables: [],
issuablesLoading: true,
showPaginationControls: wrapper.vm.showPaginationControls,
defaultPageSize: 2, // mocked value in tests
totalItems: 0,
currentPage: 1,
previousPage: 0,
nextPage: 2,
urlParams: wrapper.vm.urlParams,
recentSearchesStorageKey: 'jira_issues',
enableLabelPermalinks: false,
});
});
describe('issuable-list events', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm, 'fetchIssues');
});
it('click-tab event changes currentState value and calls fetchIssues via `fetchIssuesBy`', () => {
getIssuableList().vm.$emit('click-tab', 'closed');
expect(wrapper.vm.currentState).toBe('closed');
expect(wrapper.vm.fetchIssues).toHaveBeenCalled();
});
it('page-change event changes currentPage value and calls fetchIssues via `fetchIssuesBy`', () => {
getIssuableList().vm.$emit('page-change', 2);
expect(wrapper.vm.currentPage).toBe(2);
expect(wrapper.vm.fetchIssues).toHaveBeenCalled();
});
it('sort event changes sortedBy value and calls fetchIssues via `fetchIssuesBy`', () => {
getIssuableList().vm.$emit('sort', 'updated_asc');
expect(wrapper.vm.sortedBy).toBe('updated_asc');
expect(wrapper.vm.fetchIssues).toHaveBeenCalled();
});
it('filter event sets `filterParams` value and calls fetchIssues', () => {
getIssuableList().vm.$emit('filter', [
{
type: 'filtered-search-term',
value: {
data: 'foo',
},
},
]);
expect(wrapper.vm.filterParams).toEqual({
search: 'foo',
});
expect(wrapper.vm.fetchIssues).toHaveBeenCalled();
});
});
});
});
export const mockProvide = {
initialState: 'opened',
initialSortBy: 'created_desc',
page: 1,
issuesFetchPath: '/gitlab-org/gitlab-test/-/integrations/jira/issues.json',
projectFullPath: 'gitlab-org/gitlab-test',
issueCreateUrl: 'https://gitlab-jira.atlassian.net/secure/CreateIssue!default.jspa',
emptyStatePath: '/assets/illustrations/issues.svg',
};
export const mockJiraIssue1 = {
project_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: [
{
name: 'backend',
color: '#EBECF0',
text_color: '#283856',
},
],
author: {
name: 'jhope',
web_url: 'https://gitlab-jira.atlassian.net/people/5e32f803e127810e82875bc1',
},
assignees: [
{
name: 'Kushal Pandya',
},
],
web_url: 'https://gitlab-jira.atlassian.net/browse/IG-31596',
references: {
relative: 'IG-31596',
},
external_tracker: 'jira',
};
export const mockJiraIssue2 = {
project_id: 1,
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-jira.atlassian.net/people/5e320a31fe03e20c9d1dccde',
},
assignees: [],
web_url: 'https://gitlab-jira.atlassian.net/browse/IG-31595',
references: {
relative: 'IG-31595',
},
external_tracker: 'jira',
};
export const mockJiraIssue3 = {
project_id: 1,
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-jira.atlassian.net/people/5e320a31fe03e20c9d1dccde',
},
assignees: [],
web_url: 'https://gitlab-jira.atlassian.net/browse/IG-31594',
references: {
relative: 'IG-31594',
},
external_tracker: 'jira',
};
export const mockJiraIssues = [mockJiraIssue1, mockJiraIssue2, mockJiraIssue3];
...@@ -14382,6 +14382,9 @@ msgstr "" ...@@ -14382,6 +14382,9 @@ msgstr ""
msgid "Integrations|Connection successful." msgid "Integrations|Connection successful."
msgstr "" msgstr ""
msgid "Integrations|Create new issue in Jira"
msgstr ""
msgid "Integrations|Default settings are inherited from the group level." msgid "Integrations|Default settings are inherited from the group level."
msgstr "" msgstr ""
...@@ -14397,6 +14400,9 @@ msgstr "" ...@@ -14397,6 +14400,9 @@ msgstr ""
msgid "Integrations|Includes commit title and branch" msgid "Integrations|Includes commit title and branch"
msgstr "" msgstr ""
msgid "Integrations|Issues created in Jira are shown here once you have created the issues in project setup in Jira."
msgstr ""
msgid "Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults." msgid "Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults."
msgstr "" msgstr ""
...@@ -14409,9 +14415,15 @@ msgstr "" ...@@ -14409,9 +14415,15 @@ msgstr ""
msgid "Integrations|Saving will update the default settings for all projects that are not using custom settings." msgid "Integrations|Saving will update the default settings for all projects that are not using custom settings."
msgstr "" msgstr ""
msgid "Integrations|Search Jira issues"
msgstr ""
msgid "Integrations|Standard" msgid "Integrations|Standard"
msgstr "" msgstr ""
msgid "Integrations|To keep this project going, create a new issue."
msgstr ""
msgid "Integrations|Update your projects on Packagist, the main Composer repository" msgid "Integrations|Update your projects on Packagist, the main Composer repository"
msgstr "" msgstr ""
......
...@@ -261,6 +261,24 @@ describe('IssuableItem', () => { ...@@ -261,6 +261,24 @@ describe('IssuableItem', () => {
expect(authorEl.text()).toBe(mockAuthor.name); expect(authorEl.text()).toBe(mockAuthor.name);
}); });
it('renders issuable author info via slot', () => {
const wrapperWithAuthorSlot = createComponent({
issuableSymbol: '#',
issuable: mockIssuable,
slots: {
reference: `
<span class="js-author">${mockAuthor.name}</span>
`,
},
});
const authorEl = wrapperWithAuthorSlot.find('.js-author');
expect(authorEl.exists()).toBe(true);
expect(authorEl.text()).toBe(mockAuthor.name);
wrapperWithAuthorSlot.destroy();
});
it('renders gl-label component for each label present within `issuable` prop', () => { it('renders gl-label component for each label present within `issuable` prop', () => {
const labelsEl = wrapper.findAll(GlLabel); const labelsEl = wrapper.findAll(GlLabel);
......
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlLoadingIcon, GlPagination } from '@gitlab/ui'; import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
...@@ -34,6 +34,31 @@ describe('IssuableListRoot', () => { ...@@ -34,6 +34,31 @@ describe('IssuableListRoot', () => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('computed', () => {
describe('skeletonItemCount', () => {
it.each`
totalItems | defaultPageSize | currentPage | returnValue
${100} | ${20} | ${1} | ${20}
${105} | ${20} | ${6} | ${5}
${7} | ${20} | ${1} | ${7}
${0} | ${20} | ${1} | ${5}
`(
'returns $returnValue when totalItems is $totalItems, defaultPageSize is $defaultPageSize and currentPage is $currentPage',
async ({ totalItems, defaultPageSize, currentPage, returnValue }) => {
wrapper.setProps({
totalItems,
defaultPageSize,
currentPage,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.skeletonItemCount).toBe(returnValue);
},
);
});
});
describe('watch', () => { describe('watch', () => {
describe('urlParams', () => { describe('urlParams', () => {
it('updates window URL reflecting props within `urlParams`', async () => { it('updates window URL reflecting props within `urlParams`', async () => {
...@@ -111,7 +136,7 @@ describe('IssuableListRoot', () => { ...@@ -111,7 +136,7 @@ describe('IssuableListRoot', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(wrapper.vm.skeletonItemCount);
}); });
it('renders issuable-item component for each item within `issuables` array', () => { it('renders issuable-item component for each item within `issuables` array', () => {
...@@ -139,7 +164,7 @@ describe('IssuableListRoot', () => { ...@@ -139,7 +164,7 @@ describe('IssuableListRoot', () => {
it('renders gl-pagination when `showPaginationControls` prop is true', async () => { it('renders gl-pagination when `showPaginationControls` prop is true', async () => {
wrapper.setProps({ wrapper.setProps({
showPaginationControls: true, showPaginationControls: true,
totalPages: 10, totalItems: 10,
}); });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
......
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