Commit 8ab77787 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch 'filter-pipelines-by-tag-name' into 'master'

Filter Pipelines by Tag Name

See merge request gitlab-org/gitlab!32470
parents 0cb98d3a e1e51f66
......@@ -53,6 +53,7 @@ const Api = {
environmentsPath: '/api/:version/projects/:id/environments',
rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw',
issuePath: '/api/:version/projects/:id/issues/:issue_iid',
tagsPath: '/api/:version/projects/:id/repository/tags',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
......@@ -564,6 +565,18 @@ const Api = {
return axios.put(url, data);
},
tags(id, query = '', options = {}) {
const url = Api.buildUrl(this.tagsPath).replace(':id', encodeURIComponent(id));
return axios.get(url, {
params: {
search: query,
per_page: DEFAULT_PER_PAGE,
...options,
},
});
},
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
......
......@@ -10,8 +10,8 @@ import NavigationControls from './nav_controls.vue';
import { getParameterByName } from '../../lib/utils/common_utils';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import PipelinesFilteredSearch from './pipelines_filtered_search.vue';
import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING } from '../constants';
import { validateParams } from '../utils';
import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
......@@ -266,10 +266,18 @@ export default {
filters.forEach(filter => {
// do not add Any for username query param, so we
// can fetch all trigger authors
if (filter.type && filter.value.data !== ANY_TRIGGER_AUTHOR) {
if (
filter.type &&
filter.value.data !== ANY_TRIGGER_AUTHOR &&
filter.type !== FILTER_TAG_IDENTIFIER
) {
this.requestData[filter.type] = filter.value.data;
}
if (filter.type === FILTER_TAG_IDENTIFIER) {
this.requestData.ref = filter.value.data;
}
if (!filter.type) {
createFlash(RAW_TEXT_WARNING, 'warning');
}
......
......@@ -4,9 +4,15 @@ import { __, s__ } from '~/locale';
import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue';
import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue';
import PipelineStatusToken from './tokens/pipeline_status_token.vue';
import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue';
import { map } from 'lodash';
export default {
userType: 'username',
branchType: 'ref',
tagType: 'tag',
statusType: 'status',
defaultTokensLength: 1,
components: {
GlFilteredSearch,
},
......@@ -20,11 +26,19 @@ export default {
required: true,
},
},
data() {
return {
internalValue: [],
};
},
computed: {
selectedTypes() {
return this.value.map(i => i.type);
},
tokens() {
return [
{
type: 'username',
type: this.$options.userType,
icon: 'user',
title: s__('Pipeline|Trigger author'),
unique: true,
......@@ -33,16 +47,27 @@ export default {
projectId: this.projectId,
},
{
type: 'ref',
type: this.$options.branchType,
icon: 'branch',
title: s__('Pipeline|Branch name'),
unique: true,
token: PipelineBranchNameToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
projectId: this.projectId,
disabled: this.selectedTypes.includes(this.$options.tagType),
},
{
type: this.$options.tagType,
icon: 'tag',
title: s__('Pipeline|Tag name'),
unique: true,
token: PipelineTagNameToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
projectId: this.projectId,
disabled: this.selectedTypes.includes(this.$options.branchType),
},
{
type: 'status',
type: this.$options.statusType,
icon: 'status',
title: s__('Pipeline|Status'),
unique: true,
......@@ -51,12 +76,20 @@ export default {
},
];
},
paramsValue() {
parsedParams() {
return map(this.params, (val, key) => ({
type: key,
value: { data: val, operator: '=' },
}));
},
value: {
get() {
return this.internalValue.length > 0 ? this.internalValue : this.parsedParams;
},
set(value) {
this.internalValue = value;
},
},
},
methods: {
onSubmit(filters) {
......@@ -69,9 +102,9 @@ export default {
<template>
<div class="row-content-block">
<gl-filtered-search
v-model="value"
:placeholder="__('Filter pipelines')"
:available-tokens="tokens"
:value="paramsValue"
@submit="onSubmit"
/>
</div>
......
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import Api from '~/api';
import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants';
import createFlash from '~/flash';
import { debounce } from 'lodash';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
tags: null,
loading: true,
};
},
created() {
this.fetchTags();
},
methods: {
fetchTags(searchTerm) {
Api.tags(this.config.projectId, searchTerm)
.then(({ data }) => {
this.tags = data.map(tag => tag.name);
this.loading = false;
})
.catch(err => {
createFlash(FETCH_TAG_ERROR_MESSAGE);
this.loading = false;
throw err;
});
},
searchTags: debounce(function debounceSearch({ data }) {
this.fetchTags(data);
}, FILTER_PIPELINES_SEARCH_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" @input="searchTags">
<template #suggestions>
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion v-for="(tag, index) in tags" :key="index" :value="tag">
{{ tag }}
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
......@@ -6,6 +6,7 @@ export const LAYOUT_CHANGE_DELAY = 300;
export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const ANY_TRIGGER_AUTHOR = 'Any';
export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status'];
export const FILTER_TAG_IDENTIFIER = 'tag';
export const TestStatus = {
FAILED: 'failed',
......@@ -15,6 +16,7 @@ export const TestStatus = {
export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.');
export const FETCH_BRANCH_ERROR_MESSAGE = __('There was a problem fetching project branches.');
export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project tags.');
export const RAW_TEXT_WARNING = s__(
'Pipeline|Raw text search is not currently supported. Please use the available search tokens.',
);
---
title: Filter Pipelines by Tag Name
merge_request: 32470
author:
type: added
......@@ -102,6 +102,7 @@ you can filter the pipeline list by:
- Trigger author
- Branch name
- Status ([since GitLab 13.1](https://gitlab.com/gitlab-org/gitlab/-/issues/217617))
- Tag ([since GitLab 13.1](https://gitlab.com/gitlab-org/gitlab/-/issues/217617))
### Run a pipeline manually
......
......@@ -16195,6 +16195,9 @@ msgstr ""
msgid "Pipeline|Stop pipeline #%{pipelineId}?"
msgstr ""
msgid "Pipeline|Tag name"
msgstr ""
msgid "Pipeline|Trigger author"
msgstr ""
......@@ -22414,6 +22417,9 @@ msgstr ""
msgid "There was a problem fetching project branches."
msgstr ""
msgid "There was a problem fetching project tags."
msgstr ""
msgid "There was a problem fetching project users."
msgstr ""
......
......@@ -725,4 +725,26 @@ describe('Api', () => {
.catch(done.fail);
});
});
describe('tags', () => {
it('fetches all tags of a particular project', done => {
const query = 'dummy query';
const options = { unused: 'option' };
const projectId = 8;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/repository/tags`;
mock.onGet(expectedUrl).reply(200, [
{
name: 'test',
},
]);
Api.tags(projectId, query, options)
.then(({ data }) => {
expect(data.length).toBe(1);
expect(data[0].name).toBe('test');
})
.then(done)
.catch(done.fail);
});
});
});
......@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import PipelinesFilteredSearch from '~/pipelines/components/pipelines_filtered_search.vue';
import { users, mockSearch, branches } from '../mock_data';
import { users, mockSearch, branches, tags } from '../mock_data';
import { GlFilteredSearch } from '@gitlab/ui';
describe('Pipelines filtered search', () => {
......@@ -15,6 +15,10 @@ describe('Pipelines filtered search', () => {
findFilteredSearch()
.props('availableTokens')
.find(token => token.type === type);
const findBranchToken = () => getSearchToken('ref');
const findTagToken = () => getSearchToken('tag');
const findUserToken = () => getSearchToken('username');
const findStatusToken = () => getSearchToken('status');
const createComponent = (params = {}) => {
wrapper = mount(PipelinesFilteredSearch, {
......@@ -31,6 +35,7 @@ describe('Pipelines filtered search', () => {
jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches });
jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags });
createComponent();
});
......@@ -49,7 +54,7 @@ describe('Pipelines filtered search', () => {
});
it('displays search tokens', () => {
expect(getSearchToken('username')).toMatchObject({
expect(findUserToken()).toMatchObject({
type: 'username',
icon: 'user',
title: 'Trigger author',
......@@ -58,7 +63,7 @@ describe('Pipelines filtered search', () => {
operators: [expect.objectContaining({ value: '=' })],
});
expect(getSearchToken('ref')).toMatchObject({
expect(findBranchToken()).toMatchObject({
type: 'ref',
icon: 'branch',
title: 'Branch name',
......@@ -67,13 +72,21 @@ describe('Pipelines filtered search', () => {
operators: [expect.objectContaining({ value: '=' })],
});
expect(getSearchToken('status')).toMatchObject({
expect(findStatusToken()).toMatchObject({
type: 'status',
icon: 'status',
title: 'Status',
unique: true,
operators: [expect.objectContaining({ value: '=' })],
});
expect(findTagToken()).toMatchObject({
type: 'tag',
icon: 'tag',
title: 'Tag name',
unique: true,
operators: [expect.objectContaining({ value: '=' })],
});
});
it('emits filterPipelines on submit with correct filter', () => {
......@@ -83,6 +96,48 @@ describe('Pipelines filtered search', () => {
expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]);
});
it('disables tag name token when branch name token is active', () => {
findFilteredSearch().vm.$emit('input', [
{ type: 'ref', value: { data: 'branch-1', operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } },
]);
return wrapper.vm.$nextTick().then(() => {
expect(findBranchToken().disabled).toBe(false);
expect(findTagToken().disabled).toBe(true);
});
});
it('disables branch name token when tag name token is active', () => {
findFilteredSearch().vm.$emit('input', [
{ type: 'tag', value: { data: 'tag-1', operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } },
]);
return wrapper.vm.$nextTick().then(() => {
expect(findBranchToken().disabled).toBe(true);
expect(findTagToken().disabled).toBe(false);
});
});
it('resets tokens disabled state on clear', () => {
findFilteredSearch().vm.$emit('clearInput');
return wrapper.vm.$nextTick().then(() => {
expect(findBranchToken().disabled).toBe(false);
expect(findTagToken().disabled).toBe(false);
});
});
it('resets tokens disabled state when clearing tokens by backspace', () => {
findFilteredSearch().vm.$emit('input', [{ type: 'filtered-search-term', value: { data: '' } }]);
return wrapper.vm.$nextTick().then(() => {
expect(findBranchToken().disabled).toBe(false);
expect(findTagToken().disabled).toBe(false);
});
});
describe('Url query params', () => {
const params = {
username: 'deja.green',
......
......@@ -560,6 +560,101 @@ export const branches = [
},
];
export const tags = [
{
name: 'tag-3',
message: '',
target: '66673b07efef254dab7d537f0433a40e61cf84fe',
commit: {
id: '66673b07efef254dab7d537f0433a40e61cf84fe',
short_id: '66673b07',
created_at: '2020-03-16T11:04:46.000-04:00',
parent_ids: ['def28bf679235071140180495f25b657e2203587'],
title: 'Update .gitlab-ci.yml',
message: 'Update .gitlab-ci.yml',
author_name: 'Administrator',
author_email: 'admin@example.com',
authored_date: '2020-03-16T11:04:46.000-04:00',
committer_name: 'Administrator',
committer_email: 'admin@example.com',
committed_date: '2020-03-16T11:04:46.000-04:00',
web_url:
'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe',
},
release: null,
protected: false,
},
{
name: 'tag-2',
message: '',
target: '66673b07efef254dab7d537f0433a40e61cf84fe',
commit: {
id: '66673b07efef254dab7d537f0433a40e61cf84fe',
short_id: '66673b07',
created_at: '2020-03-16T11:04:46.000-04:00',
parent_ids: ['def28bf679235071140180495f25b657e2203587'],
title: 'Update .gitlab-ci.yml',
message: 'Update .gitlab-ci.yml',
author_name: 'Administrator',
author_email: 'admin@example.com',
authored_date: '2020-03-16T11:04:46.000-04:00',
committer_name: 'Administrator',
committer_email: 'admin@example.com',
committed_date: '2020-03-16T11:04:46.000-04:00',
web_url:
'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe',
},
release: null,
protected: false,
},
{
name: 'tag-1',
message: '',
target: '66673b07efef254dab7d537f0433a40e61cf84fe',
commit: {
id: '66673b07efef254dab7d537f0433a40e61cf84fe',
short_id: '66673b07',
created_at: '2020-03-16T11:04:46.000-04:00',
parent_ids: ['def28bf679235071140180495f25b657e2203587'],
title: 'Update .gitlab-ci.yml',
message: 'Update .gitlab-ci.yml',
author_name: 'Administrator',
author_email: 'admin@example.com',
authored_date: '2020-03-16T11:04:46.000-04:00',
committer_name: 'Administrator',
committer_email: 'admin@example.com',
committed_date: '2020-03-16T11:04:46.000-04:00',
web_url:
'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe',
},
release: null,
protected: false,
},
{
name: 'master-tag',
message: '',
target: '66673b07efef254dab7d537f0433a40e61cf84fe',
commit: {
id: '66673b07efef254dab7d537f0433a40e61cf84fe',
short_id: '66673b07',
created_at: '2020-03-16T11:04:46.000-04:00',
parent_ids: ['def28bf679235071140180495f25b657e2203587'],
title: 'Update .gitlab-ci.yml',
message: 'Update .gitlab-ci.yml',
author_name: 'Administrator',
author_email: 'admin@example.com',
authored_date: '2020-03-16T11:04:46.000-04:00',
committer_name: 'Administrator',
committer_email: 'admin@example.com',
committed_date: '2020-03-16T11:04:46.000-04:00',
web_url:
'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe',
},
release: null,
protected: false,
},
];
export const mockSearch = [
{ type: 'username', value: { data: 'root', operator: '=' } },
{ type: 'ref', value: { data: 'master', operator: '=' } },
......@@ -567,3 +662,5 @@ export const mockSearch = [
];
export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11'];
export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'master-tag'];
......@@ -22,10 +22,9 @@ describe('Pipeline Branch Name Token', () => {
type: 'ref',
icon: 'branch',
title: 'Branch name',
dataType: 'ref',
unique: true,
branches,
projectId: '21',
disabled: false,
},
value: {
data: '',
......@@ -83,7 +82,7 @@ describe('Pipeline Branch Name Token', () => {
});
describe('shows branches correctly', () => {
it('renders all trigger authors', () => {
it('renders all branches', () => {
createComponent({ stubs }, { branches, loading: false });
expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length);
......
import Api from '~/api';
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PipelineTagNameToken from '~/pipelines/components/tokens/pipeline_tag_name_token.vue';
import { tags, mockTagsAfterMap } from '../mock_data';
describe('Pipeline Branch Name Token', () => {
let wrapper;
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const stubs = {
GlFilteredSearchToken: {
template: `<div><slot name="suggestions"></slot></div>`,
},
};
const defaultProps = {
config: {
type: 'tag',
icon: 'tag',
title: 'Tag name',
unique: true,
projectId: '21',
disabled: false,
},
value: {
data: '',
},
};
const createComponent = (options, data) => {
wrapper = shallowMount(PipelineTagNameToken, {
propsData: {
...defaultProps,
},
data() {
return {
...data,
};
},
...options,
});
};
beforeEach(() => {
jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags });
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('passes config correctly', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
it('fetches and sets project tags', () => {
expect(Api.tags).toHaveBeenCalled();
expect(wrapper.vm.tags).toEqual(mockTagsAfterMap);
expect(findLoadingIcon().exists()).toBe(false);
});
describe('displays loading icon correctly', () => {
it('shows loading icon', () => {
createComponent({ stubs }, { loading: true });
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not show loading icon', () => {
createComponent({ stubs }, { loading: false });
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('shows tags correctly', () => {
it('renders all tags', () => {
createComponent({ stubs }, { tags, loading: false });
expect(findAllFilteredSearchSuggestions()).toHaveLength(tags.length);
});
it('renders only the tag searched for', () => {
const mockTags = ['master-tag'];
createComponent({ stubs }, { tags: mockTags, loading: false });
expect(findAllFilteredSearchSuggestions()).toHaveLength(mockTags.length);
});
});
});
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