Commit 1fe5d1d7 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 5b3e480f 5b14c527
......@@ -119,7 +119,7 @@ gem 'fog-aws', '~> 3.5'
# Locked until fog-google resolves https://github.com/fog/fog-google/issues/421.
# Also see config/initializers/fog_core_patch.rb.
gem 'fog-core', '= 2.1.0'
gem 'fog-google', '~> 1.10'
gem 'fog-google', '~> 1.11'
gem 'fog-local', '~> 0.6'
gem 'fog-openstack', '~> 1.0'
gem 'fog-rackspace', '~> 0.1.1'
......
......@@ -369,11 +369,12 @@ GEM
excon (~> 0.58)
formatador (~> 0.2)
mime-types
fog-google (1.10.0)
fog-google (1.11.0)
fog-core (<= 2.1.0)
fog-json (~> 1.2)
fog-xml (~> 0.1.0)
google-api-client (>= 0.32, < 0.34)
google-cloud-env (~> 1.2)
fog-json (1.2.0)
fog-core
multi_json (~> 1.10)
......@@ -475,6 +476,8 @@ GEM
representable (~> 3.0)
retriable (>= 2.0, < 4.0)
signet (~> 0.12)
google-cloud-env (1.4.0)
faraday (>= 0.17.3, < 2.0)
google-protobuf (3.12.4)
googleapis-common-protos-types (1.0.5)
google-protobuf (~> 3.11)
......@@ -1325,7 +1328,7 @@ DEPENDENCIES
fog-aliyun (~> 0.3)
fog-aws (~> 3.5)
fog-core (= 2.1.0)
fog-google (~> 1.10)
fog-google (~> 1.11)
fog-local (~> 0.6)
fog-openstack (~> 1.0)
fog-rackspace (~> 0.1.1)
......
......@@ -116,7 +116,8 @@ export default {
<div data-testid="issuable-title" class="issue-title title">
<span class="issue-title-text" dir="auto">
<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>
</span>
</div>
......@@ -134,7 +135,9 @@ export default {
>{{ createdAt }}</span
>
{{ __('by') }}
<slot v-if="hasSlotContents('author')" name="author"></slot>
<gl-link
v-else
:data-user-id="authorId"
:data-username="author.username"
:data-name="author.name"
......
<script>
import { GlLoadingIcon, GlPagination } from '@gitlab/ui';
import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
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
import IssuableTabs from './issuable_tabs.vue';
import IssuableItem from './issuable_item.vue';
import { DEFAULT_SKELETON_COUNT } from '../constants';
export default {
components: {
GlLoadingIcon,
GlSkeletonLoading,
IssuableTabs,
FilteredSearchBar,
IssuableItem,
......@@ -88,7 +90,7 @@ export default {
required: false,
default: 20,
},
totalPages: {
totalItems: {
type: Number,
required: false,
default: 0,
......@@ -114,6 +116,19 @@ export default {
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: {
urlParams: {
deep: true,
......@@ -157,7 +172,11 @@ export default {
@onSort="$emit('sort', $event)"
/>
<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
v-if="!issuablesLoading && issuables.length"
class="content-list issuable-list issues-list"
......@@ -172,6 +191,9 @@ export default {
<template #reference>
<slot name="reference" :issuable="issuable"></slot>
</template>
<template #author>
<slot name="author" :author="issuable.author"></slot>
</template>
<template #status>
<slot name="status" :issuable="issuable"></slot>
</template>
......@@ -181,7 +203,7 @@ export default {
<gl-pagination
v-if="showPaginationControls"
:per-page="defaultPageSize"
:total-items="totalPages"
:total-items="totalItems"
:value="currentPage"
:prev-page="previousPage"
:next-page="nextPage"
......
......@@ -47,3 +47,5 @@ export const AvailableSortOptions = [
];
export const DEFAULT_PAGE_SIZE = 20;
export const DEFAULT_SKELETON_COUNT = 5;
import initIssuablesList from '~/issues_list';
document.addEventListener('DOMContentLoaded', () => {
initIssuablesList();
});
<script>
import { GlForm, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui';
import {
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlForm,
GlFormGroup,
GlFormInput,
GlFormTextarea,
} from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
GlForm,
GlFormGroup,
GlFormInput,
......@@ -17,6 +30,24 @@ export default {
type: String,
required: true,
},
templates: {
type: Array,
required: false,
default: null,
},
currentTemplate: {
type: Object,
required: false,
default: null,
},
},
computed: {
dropdownLabel() {
return this.currentTemplate ? this.currentTemplate.name : __('None');
},
hasTemplates() {
return this.templates?.length > 0;
},
},
mounted() {
this.preSelect();
......@@ -30,6 +61,9 @@ export default {
this.$refs.title.$el.select();
});
},
onChangeTemplate(template) {
this.$emit('changeTemplate', template || null);
},
onUpdate(field, value) {
const payload = {
title: this.title,
......@@ -58,6 +92,29 @@ export default {
/>
</gl-form-group>
<gl-form-group
v-if="hasTemplates"
key="template"
:label="__('Description template')"
:label-for="getId('control', 'template')"
>
<gl-dropdown :text="dropdownLabel">
<gl-dropdown-item key="none" @click="onChangeTemplate(null)">
{{ __('None') }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-item
v-for="template in templates"
:key="template.key"
@click="onChangeTemplate(template)"
>
{{ template.name }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
<gl-form-group
key="description"
:label="__('Goal of the changes and what reviewers should be aware of')"
......
......@@ -22,6 +22,8 @@ export default {
data() {
return {
clearStorage: false,
currentTemplate: null,
mergeRequestTemplates: null,
mergeRequestMeta: {
title: sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), {
sourcePath: this.sourcePath,
......@@ -61,6 +63,13 @@ export default {
onSecondary() {
this.hide();
},
onChangeTemplate(template) {
this.currentTemplate = template;
const description = this.currentTemplate ? this.currentTemplate.content : '';
const mergeRequestMeta = { ...this.mergeRequestMeta, description };
this.onUpdateSettings(mergeRequestMeta);
},
onUpdateSettings(mergeRequestMeta) {
this.mergeRequestMeta = { ...mergeRequestMeta };
},
......@@ -91,7 +100,10 @@ export default {
ref="editMetaControls"
:title="mergeRequestMeta.title"
:description="mergeRequestMeta.description"
:templates="mergeRequestTemplates"
:current-template="currentTemplate"
@updateSettings="onUpdateSettings"
@changeTemplate="onChangeTemplate"
/>
</gl-modal>
</template>
---
title: Upgrade fog-google to v1.11.0
merge_request: 46648
author:
type: fixed
---
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
......@@ -340,10 +340,10 @@ These results can also be placed into a PostgreSQL database by setting the
`RSPEC_PROFILING_POSTGRES_URL` variable. This is used to profile the test suite
when running in the CI environment.
We store these results also when running CI jobs on the default branch on
`gitlab.com`. Statistics of these profiling data are [available
online](https://gitlab-org.gitlab.io/rspec_profiling_stats/). For example,
you can find which tests take longest to run or which execute the most
We store these results also when running nightly scheduled CI jobs on the
default branch on `gitlab.com`. Statistics of these profiling data are
[available online](https://gitlab-org.gitlab.io/rspec_profiling_stats/). For
example, you can find which tests take longest to run or which execute the most
queries. This can be handy for optimizing our tests or identifying performance
issues in our code.
......
......@@ -5,4 +5,5 @@ export default {
epics: 'epics-recent-searches',
requirements: 'requirements-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();
}
});
......@@ -144,57 +144,50 @@ export default {
{
key: 'detected',
label: s__('Vulnerability|Detected'),
thClass: 'detected',
tdClass: 'detected',
class: 'detected',
sortable: this.isSortable,
},
{
key: 'state',
label: s__('Vulnerability|Status'),
thClass: 'status',
tdClass: 'status',
class: 'status',
sortable: this.isSortable,
},
{
key: 'severity',
label: s__('Vulnerability|Severity'),
thClass: 'severity',
tdClass: 'severity',
class: 'severity',
sortable: this.isSortable,
},
{
key: 'title',
label: __('Description'),
thClass: 'description',
tdClass: 'description gl-word-break-all',
class: 'description gl-word-break-all',
sortable: this.isSortable,
},
{
key: 'identifier',
label: s__('Vulnerability|Identifier'),
thClass: 'identifier',
tdClass: 'identifier gl-word-break-all',
class: 'identifier gl-word-break-all',
},
{
key: 'reportType',
label: s__('Reports|Scanner'),
thClass: 'scanner',
tdClass: 'scanner',
class: 'scanner',
sortable: this.isSortable,
},
{
key: 'activity',
label: s__('Vulnerability|Activity'),
thClass: 'gl-text-right activity',
tdClass: 'activity',
thClass: 'gl-text-right',
class: 'activity',
},
];
if (this.shouldShowSelection) {
baseFields.unshift({
key: 'checkbox',
thClass: 'checkbox',
tdClass: 'checkbox',
class: 'checkbox',
});
}
......
......@@ -12,6 +12,7 @@ module Projects
before_action do
push_frontend_feature_flag(:jira_issues_integration, project, type: :licensed, default_enabled: true)
push_frontend_feature_flag(:jira_issues_list, project, type: :development)
end
rescue_from ::Projects::Integrations::Jira::IssuesFinder::IntegrationError, with: :render_integration_error
......
- page_title _('Jira Issues')
- 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 '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,
'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
'sort-key': @sort,
......
......@@ -9,6 +9,7 @@ RSpec.describe 'Jira issues list' do
before do
stub_licensed_features(jira_issues_integration: true)
stub_feature_flags(jira_issues_list: false)
project.add_user(user, :developer)
sign_in(user)
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];
......@@ -9109,6 +9109,9 @@ msgstr ""
msgid "Description parsed with %{link_start}GitLab Flavored Markdown%{link_end}"
msgstr ""
msgid "Description template"
msgstr ""
msgid "Description templates allow you to define context-specific templates for issue and merge request description fields for your project."
msgstr ""
......@@ -14403,6 +14406,9 @@ msgstr ""
msgid "Integrations|Connection successful."
msgstr ""
msgid "Integrations|Create new issue in Jira"
msgstr ""
msgid "Integrations|Default settings are inherited from the group level."
msgstr ""
......@@ -14418,6 +14424,9 @@ msgstr ""
msgid "Integrations|Includes commit title and branch"
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."
msgstr ""
......@@ -14430,9 +14439,15 @@ msgstr ""
msgid "Integrations|Saving will update the default settings for all projects that are not using custom settings."
msgstr ""
msgid "Integrations|Search Jira issues"
msgstr ""
msgid "Integrations|Standard"
msgstr ""
msgid "Integrations|To keep this project going, create a new issue."
msgstr ""
msgid "Integrations|Update your projects on Packagist, the main Composer repository"
msgstr ""
......
......@@ -261,6 +261,24 @@ describe('IssuableItem', () => {
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', () => {
const labelsEl = wrapper.findAll(GlLabel);
......
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';
......@@ -34,6 +34,31 @@ describe('IssuableListRoot', () => {
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('urlParams', () => {
it('updates window URL reflecting props within `urlParams`', async () => {
......@@ -111,7 +136,7 @@ describe('IssuableListRoot', () => {
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', () => {
......@@ -139,7 +164,7 @@ describe('IssuableListRoot', () => {
it('renders gl-pagination when `showPaginationControls` prop is true', async () => {
wrapper.setProps({
showPaginationControls: true,
totalPages: 10,
totalItems: 10,
});
await wrapper.vm.$nextTick();
......
import { shallowMount } from '@vue/test-utils';
import { GlFormInput, GlFormTextarea } from '@gitlab/ui';
import { GlDropdown, GlDropdownItem, GlFormInput, GlFormTextarea } from '@gitlab/ui';
import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue';
import { mergeRequestMeta } from '../mock_data';
import { mergeRequestMeta, mergeRequestTemplates } from '../mock_data';
describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
let wrapper;
......@@ -19,6 +19,8 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
propsData: {
title,
description,
templates: mergeRequestTemplates,
currentTemplate: null,
...propsData,
},
});
......@@ -31,6 +33,10 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
};
const findGlFormInputTitle = () => wrapper.find(GlFormInput);
const findGlDropdownDescriptionTemplate = () => wrapper.find(GlDropdown);
const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findDropdownItemByIndex = index => findAllDropdownItems().at(index);
const findGlFormTextAreaDescription = () => wrapper.find(GlFormTextarea);
beforeEach(() => {
......@@ -49,6 +55,10 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
expect(findGlFormInputTitle().exists()).toBe(true);
});
it('renders the description template dropdown', () => {
expect(findGlDropdownDescriptionTemplate().exists()).toBe(true);
});
it('renders the description input', () => {
expect(findGlFormTextAreaDescription().exists()).toBe(true);
});
......@@ -65,6 +75,11 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
expect(mockGlFormInputTitleInstance.$el.select).toHaveBeenCalled();
});
it('renders a GlDropdownItem per template plus one (for the starting none option)', () => {
expect(findDropdownItemByIndex(0).text()).toBe('None');
expect(findAllDropdownItems().length).toBe(mergeRequestTemplates.length + 1);
});
describe('when inputs change', () => {
const storageKey = 'sse-merge-request-meta-local-storage-editable';
......@@ -84,4 +99,17 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
expect(wrapper.emitted('updateSettings')[0][0]).toMatchObject(newSettings);
});
});
describe('when templates change', () => {
it.each`
index | value
${0} | ${null}
${1} | ${mergeRequestTemplates[0]}
${2} | ${mergeRequestTemplates[1]}
`('emits a change template event when $index is clicked', ({ index, value }) => {
findDropdownItemByIndex(index).vm.$emit('click');
expect(wrapper.emitted('changeTemplate')[0][0]).toBe(value);
});
});
});
......@@ -5,7 +5,7 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import EditMetaModal from '~/static_site_editor/components/edit_meta_modal.vue';
import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue';
import { MR_META_LOCAL_STORAGE_KEY } from '~/static_site_editor/constants';
import { sourcePath, mergeRequestMeta } from '../mock_data';
import { sourcePath, mergeRequestMeta, mergeRequestTemplates } from '../mock_data';
describe('~/static_site_editor/components/edit_meta_modal.vue', () => {
useLocalStorageSpy();
......@@ -15,12 +15,13 @@ describe('~/static_site_editor/components/edit_meta_modal.vue', () => {
let mockEditMetaControlsInstance;
const { title, description } = mergeRequestMeta;
const buildWrapper = (propsData = {}) => {
const buildWrapper = (propsData = {}, data = {}) => {
wrapper = shallowMount(EditMetaModal, {
propsData: {
sourcePath,
...propsData,
},
data: () => data,
});
};
......@@ -51,7 +52,12 @@ describe('~/static_site_editor/components/edit_meta_modal.vue', () => {
});
it('initializes initial merge request meta with local storage data', async () => {
const localStorageMeta = { title: 'stored title', description: 'stored description' };
const localStorageMeta = {
title: 'stored title',
description: 'stored description',
templates: null,
currentTemplate: null,
};
findLocalStorageSync().vm.$emit('input', localStorageMeta);
......@@ -80,6 +86,14 @@ describe('~/static_site_editor/components/edit_meta_modal.vue', () => {
expect(findEditMetaControls().props('description')).toBe(description);
});
it('forwards the templates prop', () => {
expect(findEditMetaControls().props('templates')).toBe(null);
});
it('forwards the currentTemplate prop', () => {
expect(findEditMetaControls().props('currentTemplate')).toBe(null);
});
describe('when save button is clicked', () => {
beforeEach(() => {
findGlModal().vm.$emit('primary', mergeRequestMeta);
......@@ -94,6 +108,36 @@ describe('~/static_site_editor/components/edit_meta_modal.vue', () => {
});
});
describe('when templates exist', () => {
const template1 = mergeRequestTemplates[0];
beforeEach(() => {
buildWrapper({}, { templates: mergeRequestTemplates, currentTemplate: null });
});
it('sets the currentTemplate on the changeTemplate event', async () => {
findEditMetaControls().vm.$emit('changeTemplate', template1);
await wrapper.vm.$nextTick();
expect(findEditMetaControls().props().currentTemplate).toBe(template1);
findEditMetaControls().vm.$emit('changeTemplate', null);
await wrapper.vm.$nextTick();
expect(findEditMetaControls().props().currentTemplate).toBe(null);
});
it('updates the description on the changeTemplate event', async () => {
findEditMetaControls().vm.$emit('changeTemplate', template1);
await wrapper.vm.$nextTick();
expect(findEditMetaControls().props().description).toEqual(template1.content);
});
});
it('emits the hide event', () => {
findGlModal().vm.$emit('hide');
expect(wrapper.emitted('hide')).toEqual([[]]);
......
......@@ -48,6 +48,10 @@ export const savedContentMeta = {
url: 'foobar/-/merge_requests/123',
},
};
export const mergeRequestTemplates = [
{ key: 'Template1', name: 'Template 1', content: 'This is template 1!' },
{ key: 'Template2', name: 'Template 2', content: 'This is template 2!' },
];
export const submitChangesError = 'Could not save changes';
export const commitBranchResponse = {
......
......@@ -517,6 +517,8 @@ module TestEnv
return false if component_matches_git_sha?(component_folder, expected_version)
return false if component_ahead_of_target?(component_folder, expected_version)
version = File.read(File.join(component_folder, 'VERSION')).strip
# Notice that this will always yield true when using branch versions
......@@ -527,6 +529,20 @@ module TestEnv
true
end
def component_ahead_of_target?(component_folder, expected_version)
# The HEAD of the component_folder will be used as heuristic for the version
# of the binaries, allowing to use Git to determine if HEAD is later than
# the expected version. Note: Git considers HEAD to be an anchestor of HEAD.
_out, exit_status = Gitlab::Popen.popen(%W[
#{Gitlab.config.git.bin_path}
-C #{component_folder}
merge-base --is-ancestor
#{expected_version} HEAD
])
exit_status == 0
end
def component_matches_git_sha?(component_folder, expected_version)
# Not a git SHA, so return early
return false unless expected_version =~ ::Gitlab::Git::COMMIT_ID
......
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